조사 결함 분류별 상세보기 및 등록 팝업 개선

This commit is contained in:
kiminchan
2026-07-24 14:08:25 +09:00
parent 11cb2b9044
commit 728981cfe6
12 changed files with 472 additions and 145 deletions
@@ -146,9 +146,9 @@
| 표 구조 | 관로·맨홀 팝업 비교 | 같은 고정 정보 열과 대·중·소 3열 구조, 조사유형별 이상항목 코드·명칭 적용 |
| 스크롤 | 표를 좌우·상하로 이동 | 팝업 문서 전체 스크롤 없음, 표 영역에서만 스크롤 |
| 고정 열 | 표를 오른쪽으로 이동 | 보고서번호·관로/맨홀번호가 고정되고 규격 이후 경계선 유지 |
| 정렬 | 고정 정보 헤더를 3회 클릭 | 보고서번호~운영적 내부결함만 오름차순→내림차순→기본 순환 |
| 정렬 | 고정 정보 헤더를 3회 클릭 | 보고서번호~주행거리만 오름차순→내림차순→기본 순환 |
| 조회 | 관로/맨홀번호 입력 후 조회 | 번호 포함 행만 표시되고 빈 조건에서 전체 3행 복원 |
| 소계 | 샘플 3행 값 합산 | 연장·주행거리·내부결함과 코드별 대·중·소 합계 일치 |
| 소계 | 샘플 3행 값 합산 | 연장·주행거리와 코드별 대·중·소 합계 일치 |
| 합계 | 이상항목 합계 행 확인 | 각 항목 대·중·소 3열 병합 후 총합 표시 |
| 요약 라벨 | 가로 스크롤 전·후 비교 | 규격 전에는 병합영역 중앙, 규격 이후에도 고정열 오른쪽에 소계·합계 라벨 표시 |
| 목록 정렬 | 조사사업 `NO` 헤더 3회 클릭 | 정순→역순→기본 순환, 서버에 가상 `rowNumber` 필드 미전달 |
@@ -158,3 +158,25 @@
- 사용자 화면 확인을 이번 프론트 상세보기 완료 기준으로 사용한다.
- 실 API 응답, JSONB 코드 매핑, 실제 엑셀 다운로드 파일 검증은 백엔드 구현 후 통합 검증으로 이관한다.
## 13. 결함 분류별 상세보기와 팝업 최종 회귀 계획
| 검증 항목 | 조작 | 기대 결과 |
| --- | --- | --- |
| 행 선택 | 상세현황 처리분구 행의 임의 셀 클릭 | 행 전체가 선택 강조되고 상단 상세보기 대상 갱신 |
| 0건 처리 | 결함 건수 0 셀 확인 | 파란 링크가 아니며 상세 팝업을 열지 않음 |
| 구조적 링크 | 구조적 결함 1건 이상 링크 클릭 | 행 선택 후 `STRUCTURAL` 상세 팝업 표시 |
| 운영적 링크 | 운영적 결함 1건 이상 링크 클릭 | 행 선택 후 `OPERATIONAL` 상세 팝업 표시 |
| 전체 상세 | 행 선택 후 상단 상세보기 클릭 | `ALL` 기준 구조·운영 이상항목을 함께 표시 |
| 조사유형 | 관로·맨홀 탭에서 각각 링크 클릭 | 선택 탭에 맞는 관로/맨홀 상세표 표시 |
| 분류 열 필터 | 전체·구조·운영 팝업 비교 | 선택 분류 코드 열만 표시 |
| 분류 행 필터 | 분류별 샘플 데이터 확인 | 선택 분류의 결함이 하나 이상인 시설 행만 표시 |
| 코드 헤더 | 이상항목 헤더 확인 | 모든 헤더가 `명칭 (코드)` 형식 |
| 제거 열 | 고정 정보 열 확인 | 구조적·운영적 내부결함 열 없음 |
| 집계 | 전체·구조·운영 각각 소계·합계 확인 | 현재 표시 행·열의 대·중·소 값과 일치 |
| 사업등록 검색 | 처리분구명 검색·결과 선택·초기화 | 필터된 목록 선택과 선택 건수 갱신 |
| 결과등록 요약 | 사업정보 영역 확인 | 읽기 전용 텍스트가 한 줄에서 잘리지 않음 |
| 회귀 | 사업등록·결과등록·상세보기 팝업 비교 | 파란 헤더와 옅은 파란 본문 배경 일관성 유지 |
- 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register?sample=Y` 진입이 선행조건이다.
- 개발 서버가 실행 중이면 별도 프로덕션 빌드를 실행하지 않고 정적 검사와 사용자 화면 확인을 우선한다.
@@ -178,8 +178,8 @@
- 관로·맨홀 화면은 같은 고정 정보 열과 대·중·소 3열 구조를 사용하며 이상항목 내용만 코드 정의에 따라 달라지는 것을 확인했다.
- 상세표 헤더 배경·글꼴을 공통 그리드와 맞추고 팝업 문서 대신 표 영역에서만 가로·세로 스크롤하는 구조를 확인했다.
- 관로/맨홀번호 입력 폭을 축소하고 번호 포함 검색으로 샘플 행을 필터링하는 동작을 확인했다.
- 보고서번호부터 운영적 내부결함까지만 정렬 버튼이 표시되고 `asc → desc → undefined`로 순환하는 소스를 확인했다.
- 소계는 연장·주행거리·구조적·운영적 내부결함과 이상항목별 대·중·소 합산값을 표시한다.
- 보고서번호부터 주행거리까지만 정렬 버튼이 표시되고 `asc → desc → undefined`로 순환하는 소스를 확인했다.
- 소계는 연장·주행거리와 이상항목별 대·중·소 합산값을 표시한다.
- 합계는 각 이상항목의 대·중·소 3열을 병합하고 총합을 표시한다.
- 소계·합계 라벨을 보고서번호부터 규격까지 가운데 배치하고, 가로 스크롤로 규격이 사라진 뒤에도 고정열 오른쪽에서 라벨과 경계선이 유지되도록 보정했다.
- 조사사업 목록 `NO` 정렬은 가상 필드를 서버에 전달하지 않고 조회 원본 정·역순으로 동작한다.
@@ -189,6 +189,20 @@
- `git diff --check`는 통과했다. 실행 중 개발 서버 보호와 로컬 TypeScript compiler의 샌드박스 `EPERM` 이력 때문에 별도 프로덕션 빌드·타입 검사는 수행하지 않았다.
- 실 상세 API, JSONB 코드 매핑, 실제 엑셀 다운로드와 지도보기는 검증되지 않았으며 후속 범위다.
## 7. 흡수한 기존 문서
## 13. 결함 분류별 상세보기와 팝업 최종 보완 검증 결과
- 구조적·운영적 결함 건수가 1개 이상일 때만 파란색 밑줄 링크와 팝업 클릭 동작이 활성화되는 조건을 소스에서 확인했다.
- 링크 클릭이 처리분구 선택과 `defectType=STRUCTURAL|OPERATIONAL` 상세 팝업 열기를 함께 수행하는 것을 확인했다.
- 상단 상세보기는 기본값 `ALL`을 전달하며 잘못된 `defectType`은 팝업 content에서 `ALL`로 정규화한다.
- 관로·맨홀 상세표 모두 선택 분류의 코드 열과 해당 결함이 존재하는 시설 행만 남기는 필터를 확인했다.
- 구조적·운영적 내부결함 고정 열과 관련 샘플 필드가 제거됐고 이상항목 헤더가 `명칭 (코드)`로 표시되는 것을 확인했다.
- 소계·합계가 필터 후 표시 행·열을 기준으로 다시 계산되는 것을 소스와 샘플 데이터로 확인했다.
- 사업등록의 처리분구 검색·검색결과 선택·초기화와 결과등록의 읽기 전용 사업정보 요약 구현을 확인했다.
- 사업등록·결과등록·상세보기 팝업이 공통 파란 헤더와 옅은 파란 본문 배경을 사용하는 것을 확인했다.
- `git diff --check` 정적 검증을 수행 대상으로 유지한다.
- 이번 문서 반영 시점에는 로그인 세션이 없어 브라우저 자동 재검증을 수행하지 못했다. 기존 사용자 화면 완료 판정을 유지하되 이번 결함 분류 링크는 로그인된 화면에서 최종 클릭 확인이 필요하다.
- 로컬 TypeScript compiler는 샌드박스 `EPERM` 이력으로 별도 실행하지 않았다. 이는 기능 실패가 아니라 실행 환경 차단으로 분류한다.
## 14. 흡수한 기존 문서
- 없음
@@ -183,9 +183,26 @@
- 공통 WindowPopup의 파란 헤더와 공통 그리드 헤더 스타일을 사용한다.
- 사업정보 요약 띠를 제거하고 처리분구·조사대상·번호 조건과 조회·다운로드를 본문 상단에 배치한다.
- 보고서번호부터 운영적 내부결함까지 정렬을 지원하고 보고서번호·관로/맨홀번호를 가로 스크롤 고정 열로 사용한다.
- 구조적·운영적 내부결함 고정 열을 제거하고 보고서번호부터 주행거리까지 정렬을 지원한다. 보고서번호·관로/맨홀번호는 가로 스크롤 고정 열로 사용한다.
- 이상항목은 관로·맨홀 코드값을 열 식별자로 유지하고 화면은 대·중·소 3열로 표시한다.
- 소계는 심각도별 값을, 합계는 항목별 병합 총합을 표시한다. 소계·합계 라벨은 규격까지의 병합영역을 사용하되 가로 스크롤 후에도 고정열 오른쪽에서 보이게 한다.
- 조사사업 목록 `NO`와 조사 상세현황의 모든 데이터 열에 정렬을 제공한다. 상세현황 합계 행은 정렬 대상에서 제외해 상단에 고정한다.
- 상세현황 버튼 순서는 `상세보기 → 지도보기 → 다운로드`이며 다운로드만 기존 초록색 스타일을 사용한다.
- 사용자 화면 확인으로 상세보기 프론트 단계는 완료했다. 실 상세 API, 엑셀 다운로드, 지도보기는 완료 범위가 아니다.
## 16. 결함 분류별 상세보기와 팝업 최종 보완 범위
| 구분 | 완료 내용 |
| --- | --- |
| 상세현황 행 선택 | 처리분구 행 전체 선택·강조와 상세보기 대상을 동기화 |
| 결함 건수 링크 | 1개 이상인 구조적·운영적 결함 건수를 파란색 밑줄 링크로 표시 |
| 팝업 분기 | 상단 상세보기 `ALL`, 건수 링크 `STRUCTURAL|OPERATIONAL` 전달 |
| 분류별 상세표 | 선택 분류의 코드 열과 결함이 있는 시설 행만 표시 |
| 코드 표기 | 관로·맨홀 이상항목 헤더를 `명칭 (코드)`로 표시 |
| 표 정리 | 구조적·운영적 내부결함 고정 열 제거, 표시 열 기준 소계·합계 재계산 |
| 사업등록 팝업 | 공통 팝업 디자인 통일, 처리분구 검색·검색결과 선택·초기화 |
| 결과등록 팝업 | 공통 팝업 디자인 통일, 사업정보를 읽기 전용 요약 텍스트로 정리 |
- 수정 대상은 `src/features/exmn/**`와 `src/features/windowPopup/**` 안으로 제한한다.
- 백엔드 API·DTO·DB는 수정하지 않는다.
- 완료 게이트는 관로·맨홀 각각 `ALL|STRUCTURAL|OPERATIONAL` 화면, 코드 헤더, 필터 결과와 소계·합계가 일치하는 것이다.
@@ -308,14 +308,48 @@
- `exmnSurveyDetail` 팝업 키와 `1500×720` definition을 추가하고 선택 사업 ID·조사유형을 필수 query로 전달한다.
- `ExmnSurveyDetailPopupContent`가 query를 `ExmnBusiness`, `PPL|MNHL`, `sample=Y` 화면 속성으로 변환하고 `ExmnSurveyDetailPopupPage`를 렌더링한다.
- `exmn-detail-table.ts`에 관로 24개, 맨홀 23개 이상항목 코드·명칭·구조/운영 구분을 화면 열 정의로 분리했다.
- 관로·맨홀 모두 보고서번호, 관로/맨홀번호, 상·하류맨홀, 배수방식, 관종, 규격, 연장, 주행거리, 구조적·운영적 내부결함을 공통 고정 정보 열로 사용한다.
- 관로·맨홀 모두 보고서번호, 관로/맨홀번호, 상·하류맨홀, 배수방식, 관종, 규격, 연장, 주행거리를 공통 고정 정보 열로 사용한다.
- 사용자 제공 엑셀 구조에 맞춰 이상항목마다 `대·중·소` 3열을 만들고 그룹·항목·심각도의 다단 병합 헤더를 직접 구성했다.
- 보고서번호부터 운영적 내부결함까지는 `오름차순 → 내림차순 → 기본`으로 정렬한다. 보고서번호와 관로/맨홀번호는 가로 스크롤 고정 열이다.
- 보고서번호부터 주행거리까지는 `오름차순 → 내림차순 → 기본`으로 정렬한다. 보고서번호와 관로/맨홀번호는 가로 스크롤 고정 열이다.
- 팝업 문서 overflow를 잠그고 표 영역에서만 가로·세로 스크롤한다.
- 현재 조회 결과의 연장·주행거리·내부결함 및 코드별 대·중·소를 합산해 소계·합계를 표시한다. 소계는 심각도별, 합계는 항목별 3열 병합 총합이다.
- 현재 조회 결과의 연장·주행거리 및 코드별 대·중·소를 합산해 소계·합계를 표시한다. 소계는 심각도별, 합계는 항목별 3열 병합 총합이다.
- 소계·합계 라벨은 보고서번호부터 규격까지의 병합영역 중앙에서 시작하고, 규격이 스크롤로 사라진 뒤에도 고정열 오른쪽에 라벨과 경계선이 남도록 `scrollLeft` 기반 위치를 보정한다.
- `sample=Y`에서 관로·맨홀 각 3행을 제공하며 일반 URL은 빈 화면과 실제 API 후속 안내를 유지한다.
- 조사사업 목록 `NO` 정렬을 추가했다. `NO`는 서버 엔티티 필드가 아니므로 서버 sort에는 전달하지 않고 현재 기본 조회 결과를 정·역순으로 표시한다.
- 조사 상세현황은 합계 행을 별도 렌더링해 고정하고 나머지 처리분구 행만 전 열 정렬한다.
- 상세현황 액션을 `상세보기 → 지도보기 → 다운로드`로 배치하고 다운로드 버튼에 기존 초록색 스타일을 적용했다.
- 백엔드 API·DTO·DB는 변경하지 않았다. 실 상세조회와 엑셀 다운로드 계약은 후속이다.
## 12. 2026-07-24 결함 분류별 상세보기와 등록 팝업 최종 보완
### 12.1 조사 상세현황 선택과 링크
- `ExmnStatusDetailPanel.tsx`에서 처리분구 행 선택 상태를 상세보기 대상과 연결했다.
- 구조적·운영적 결함 건수가 1개 이상인 셀은 파란색 밑줄 링크로 렌더링한다.
- 링크 클릭 시 해당 행을 선택·강조하고 기존 `exmnSurveyDetail` WindowPopup을 연다.
- 상단 상세보기는 `defectType=ALL`, 구조적 링크는 `STRUCTURAL`, 운영적 링크는 `OPERATIONAL`을 전달한다.
- 건수가 0이면 일반 텍스트로 유지해 잘못된 빈 상세 팝업 진입을 막는다.
### 12.2 상세 팝업 필터
- `ExmnSurveyDetailPopupContent.tsx`가 `defectType`을 파싱하고 누락되거나 잘못된 값은 `ALL`로 정규화한다.
- `ExmnSurveyDetailPopupPage.tsx`는 선택 분류의 이상항목 열만 남기고 해당 열에 결함 건수가 있는 시설 행만 표시한다.
- 관로·맨홀 모두 동일한 필터 규칙을 사용하며 팝업 제목과 집계표 제목에 선택 분류를 표시한다.
- 구조적·운영적 내부결함 고정 열과 샘플 필드를 제거했다.
- 표시된 이상항목 헤더는 `명칭 (코드)` 형식이며 소계·합계는 현재 표시 행과 열 기준으로 재계산한다.
- 소계·합계 라벨 병합 범위는 보고서번호부터 규격까지, 실제 수치 합산 시작은 연장부터 유지한다.
### 12.3 사업등록·결과등록 WindowPopup
- `ExmnBusinessRegisterPopupPage.tsx`와 `ExmnResultRegisterPopupPage.tsx`를 상세보기와 같은 파란 헤더·옅은 파란 본문 배경으로 통일했다.
- 사업등록 처리분구 영역에 이름 검색, 검색결과 선택, 선택 초기화를 추가하고 선택 건수만 노출한다.
- 조사결과 업로드의 사업차수·사업연도·행정구역·사업명은 입력 컴포넌트처럼 보이지 않는 읽기 전용 텍스트로 정리했다.
- 팝업 크기와 본문 배치를 조정해 사업정보와 파일 업로드 표가 잘리지 않게 했다.
### 12.4 수정 파일과 연동 경계
- `src/features/exmn/model/{exmn-detail-table,exmn-sample-data}.ts`
- `src/features/exmn/ui/{ExmnBusinessRegisterPopupPage,ExmnResultRegisterPopupPage,ExmnStatusDetailPanel,ExmnSurveyDetailPopupPage}.tsx`
- `src/features/windowPopup/model/popupDefinitions.ts`
- `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx`
- 백엔드 코드·API·DTO·DB는 변경하지 않았다. 후속 API는 `jobType`, 처리분구 식별자, `defectType`과 JSONB 코드별 건수 계약을 사용해야 한다.
@@ -5,6 +5,7 @@ export const EXMN_DETAIL_SEVERITIES = ["L", "M", "S"] as const;
export type ExmnDetailSeverity = (typeof EXMN_DETAIL_SEVERITIES)[number];
export type ExmnDefectGroup = "STRUCTURAL" | "OPERATIONAL";
export type ExmnDetailDefectType = "ALL" | ExmnDefectGroup;
export type ExmnSeverityCounts = Record<ExmnDetailSeverity, number>;
/** 화면 열과 추후 JSONB 코드 매핑이 같은 식별자를 사용하도록 하는 이상항목 정의다. */
@@ -25,8 +26,6 @@ export interface ExmnSurveyDetailRow {
specification: number;
extensionM: number;
drivingDistanceM: number;
structuralInternalDefect: number;
operationalInternalDefect: number;
anomalyTotals: ExmnSeverityCounts;
defectCounts: Record<string, ExmnSeverityCounts>;
}
@@ -239,8 +239,6 @@ export const getExmnSampleSurveyDetails = (
drainageType: "합류",
pipeType: "HP",
specification: 600,
structuralInternalDefect: rowIndex === 1 ? 3 : 1,
operationalInternalDefect: 1,
anomalyTotals,
defectCounts,
};
@@ -23,10 +23,8 @@ import type {
} from "../model/exmn-business-types";
import type { TrtdstSettingDto } from "../model/trtdst-types";
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 DOWNLOAD_BUTTON_CLASS =
"green rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white";
"rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white";
type Notice = { type: "success" | "error" | "info"; text: string } | null;
@@ -59,6 +57,8 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
const [sggNm, setSggNm] = useState(sampleMode ? "양평군" : "");
const [prcisExmnPrlg, setPrcisExmnPrlg] = useState(sampleMode ? "18.42" : "0");
const [districts, setDistricts] = useState<TrtdstSettingDto[]>([]);
/** 등록 후보가 많을 때 처리분구명으로 화면 목록을 좁히는 검색어다. */
const [districtKeyword, setDistrictKeyword] = useState("");
const [selectedDistrictIds, setSelectedDistrictIds] = useState<string[]>([]);
const [savedBusiness, setSavedBusiness] = useState<ExmnBusinessDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -81,6 +81,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
/** 선택 행정구역의 활성 처리분구를 읽고 화면 요구사항대로 가나다순으로 제공한다. */
useEffect(() => {
setDistrictKeyword("");
if (!ctpvNm || !sggNm) {
setDistricts([]);
setSelectedDistrictIds([]);
@@ -126,6 +127,33 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
const selectedDistricts = useMemo(() => districts.filter((district) =>
selectedDistrictIds.includes(district.trtdstId)), [districts, selectedDistrictIds]);
/** 공백 차이를 무시한 처리분구명 부분일치 검색 결과다. */
const filteredDistricts = useMemo(() => {
const keyword = districtKeyword.replace(/\s+/g, "").toLocaleLowerCase("ko-KR");
if (!keyword) return districts;
return districts.filter((district) =>
district.trtdstNm.replace(/\s+/g, "").toLocaleLowerCase("ko-KR").includes(keyword));
}, [districtKeyword, districts]);
/** 현재 검색 결과가 모두 선택된 상태인지 일괄선택 버튼 문구와 동작에 사용한다. */
const allFilteredDistrictsSelected = filteredDistricts.length > 0
&& filteredDistricts.every((district) => selectedDistrictIds.includes(district.trtdstId));
/** 검색된 처리분구만 한 번에 선택하거나 해제하며 다른 검색에서 고른 값은 유지한다. */
const handleToggleFilteredDistricts = () => {
const filteredIds = new Set(filteredDistricts.map((district) => district.trtdstId));
setSelectedDistrictIds((current) => {
const nextIds = new Set(current);
filteredIds.forEach((id) => {
if (allFilteredDistrictsSelected) nextIds.delete(id);
else nextIds.add(id);
});
return districts
.filter((district) => nextIds.has(district.trtdstId))
.map((district) => district.trtdstId);
});
};
/** 현재 입력값을 조사사업 신규 저장 계약으로 변환한다. */
const buildRequest = (): ExmnBusinessSaveRequest => ({
bizYr: bizYr.trim(),
@@ -217,18 +245,27 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
};
return (
<div className="min-h-dvh bg-[#f3f7fa] p-4">
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="flex items-center justify-between rounded-t border border-[#d7e0e8] bg-white px-5 py-4">
<h1 className="text-xl font-semibold text-gray-800">사업정보 등록</h1>
<header className="sn-popup-header">
<h1 className="sn-popup-title">사업정보 등록</h1>
<div className="flex items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading || Boolean(savedBusiness)} onClick={() => void handleSave()}>저장</Button>
<Button className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleDownloadTemplates()}>양식 받기</Button>
<Button className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={handleReportTemplate}>조사보고서 양식</Button>
<Button
type="button"
color="white"
size="xs"
className="sn-popup-header-button"
disabled={isLoading || Boolean(savedBusiness)}
onClick={() => void handleSave()}
>
저장
</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>
</div>
</header>
<main className="space-y-3 border-x border-b border-[#d7e0e8] bg-white p-4">
<section className="sn-popup-section space-y-3 overflow-y-auto">
{notice && (
<div role="alert" className={`rounded border px-4 py-3 text-sm ${
notice.type === "error"
@@ -239,8 +276,8 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
}`}>{notice.text}</div>
)}
<section className="rounded border border-[#d7e0e8]">
<div className="border-b border-[#d7e0e8] bg-[#e8f2fb] px-4 py-3 font-semibold text-gray-700">사업정보 입력</div>
<section className="border border-border">
<div className="border-b border-border bg-[#eaf3fb] px-4 py-3 font-semibold text-title">사업정보 입력</div>
<div className="space-y-4 p-4">
<div className="flex flex-wrap items-center gap-y-4">
<SelectBox
@@ -296,15 +333,50 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
</div>
</section>
<section className="rounded border border-[#d7e0e8]">
<div className="flex items-center justify-between border-b border-[#d7e0e8] bg-[#e8f2fb] px-4 py-3">
<h2 className="font-semibold text-gray-700">사업대상 처리분구</h2>
<span className="text-sm text-gray-600">가나다순 · {selectedDistrictIds.length}개 선택</span>
<section className="min-h-0 border border-border">
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-[#eaf3fb] px-4 py-2">
<h2 className="mr-2 font-semibold text-title">사업대상 처리분구</h2>
<Input
type="search"
value={districtKeyword}
className="sn-popup-input-sm !h-9 w-[22rem] bg-white"
placeholder="처리분구명 검색"
aria-label="처리분구명 검색"
disabled={Boolean(savedBusiness)}
onChange={(event) => setDistrictKeyword(event.target.value)}
/>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="sn-popup-secondary-button-sm"
disabled={Boolean(savedBusiness) || filteredDistricts.length === 0}
onClick={handleToggleFilteredDistricts}
>
{allFilteredDistrictsSelected ? "검색결과 해제" : "검색결과 선택"}
</Button>
<Button
type="button"
color="white"
size="xs"
className="sn-popup-secondary-button-sm"
disabled={Boolean(savedBusiness) || selectedDistrictIds.length === 0}
onClick={() => setSelectedDistrictIds([])}
>
선택 초기화
</Button>
<span className="ml-1 whitespace-nowrap text-sm text-label">
{selectedDistrictIds.length}개 선택
</span>
</div>
</div>
<div className="grid grid-cols-3 content-start gap-3 overflow-y-auto p-4" style={{ height: "21rem" }}>
{districts.length === 0 ? (
<p className="col-span-3 py-7 text-center text-sm text-gray-500">시도·시군구를 선택하거나 등록된 활성 처리분구를 확인해주세요.</p>
) : districts.map((district) => (
) : filteredDistricts.length === 0 ? (
<p className="col-span-3 py-7 text-center text-sm text-gray-500">검색 조건에 맞는 처리분구가 없습니다.</p>
) : filteredDistricts.map((district) => (
<label key={district.trtdstId} className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-white px-3 py-2.5 text-sm hover:border-blue-300 hover:bg-blue-50">
<input
type="checkbox"
@@ -321,7 +393,7 @@ export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegist
</div>
</section>
</main>
</div>
</section>
</main>
);
}
@@ -294,16 +294,25 @@ export function ExmnResultRegisterPopupPage({
};
return (
<div className="flex h-dvh w-full max-w-full flex-col overflow-hidden bg-[#f3f7fa] p-2">
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="flex shrink-0 items-center justify-between rounded-t border border-[#d7e0e8] bg-white px-4 py-2.5">
<h1 className="text-xl font-semibold text-gray-800">조사 결과 업로드</h1>
<header className="sn-popup-header">
<h1 className="sn-popup-title">조사 결과 업로드</h1>
<div>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading || !validation?.valid} onClick={() => void handleCommit()}>저장</Button>
<Button
type="button"
color="white"
size="xs"
className="sn-popup-header-button"
disabled={isLoading || !validation?.valid}
onClick={() => void handleCommit()}
>
저장
</Button>
</div>
</header>
<main className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto border-x border-b border-[#d7e0e8] bg-white p-2">
<section className="flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto px-4 pb-3 pt-3">
{notice && (
<div role="alert" className={`rounded border px-4 py-3 text-sm ${
notice.type === "error"
@@ -315,30 +324,46 @@ export function ExmnResultRegisterPopupPage({
)}
<section
className="grid gap-8 rounded border border-[#d7e0e8] bg-[#f8fafc] px-5 py-2.5 text-sm"
style={{ gridTemplateColumns: "0.7fr 0.8fr 1.2fr 2.3fr" }}
className="rounded-md border border-border bg-secondary p-3"
aria-label="선택 조사사업 요약"
>
<div className="flex items-center gap-2">
<strong className="shrink-0 text-gray-700">사업차수</strong>
<span className="text-gray-600">{detail?.bizCycl ?? businessSummary.bizCycl ?? "-"}차</span>
</div>
<div className="flex items-center gap-2">
<strong className="shrink-0 text-gray-700">사업년도</strong>
<span className="text-gray-600">{detail?.bizYr ?? businessSummary.bizYr ?? "-"}</span>
</div>
<div className="flex min-w-0 items-center gap-2">
<strong className="shrink-0 text-gray-700">행정구역</strong>
<span className="truncate text-gray-600">{detail?.ctpvNm ?? businessSummary.ctpvNm} {detail?.sggNm ?? businessSummary.sggNm}</span>
</div>
<div className="flex min-w-0 items-center gap-2">
<strong className="shrink-0 text-gray-700">사업명</strong>
<span className="truncate text-gray-600" title={detail?.bizNm ?? businessSummary.bizNm}>{detail?.bizNm ?? businessSummary.bizNm ?? "-"}</span>
<div className="flex min-w-0 flex-wrap items-center gap-x-7 gap-y-2">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">사업차수</span>
<span className="whitespace-nowrap text-[1.05rem] font-semibold text-foreground">
{detail?.bizCycl ?? businessSummary.bizCycl ?? "-"}차
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">사업년도</span>
<span className="whitespace-nowrap text-[1.05rem] font-semibold text-foreground">
{detail?.bizYr ?? businessSummary.bizYr ?? "-"}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">행정구역</span>
<span
className="max-w-[22rem] truncate text-[1.05rem] font-semibold text-foreground"
title={`${detail?.ctpvNm ?? businessSummary.ctpvNm} ${detail?.sggNm ?? businessSummary.sggNm}`}
>
{detail?.ctpvNm ?? businessSummary.ctpvNm} {detail?.sggNm ?? businessSummary.sggNm}
</span>
</div>
<div className="flex min-w-[24rem] flex-1 items-center gap-2">
<span className="shrink-0 whitespace-nowrap text-[1.05rem] font-medium text-label">사업명</span>
<span
className="min-w-0 truncate text-[1.05rem] font-semibold text-foreground"
title={detail?.bizNm ?? businessSummary.bizNm}
>
{detail?.bizNm ?? businessSummary.bizNm ?? "-"}
</span>
</div>
</div>
</section>
<section className="overflow-hidden rounded border border-[#d7e0e8]">
<section className="overflow-hidden border border-border">
<div
className="grid items-center gap-3 bg-[#e8f2fb] px-3 py-2.5 text-center text-sm font-semibold text-gray-700"
className="grid items-center gap-3 bg-[#eaf3fb] px-3 py-2.5 text-center text-sm font-semibold text-title"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<span>파일 구분</span><span>선택 파일</span><span>찾기</span><span>데이터 검토</span>
@@ -351,7 +376,7 @@ export function ExmnResultRegisterPopupPage({
return (
<div
key={fileType}
className="grid items-center gap-3 border-t border-[#d7e0e8] px-3 py-3 text-sm"
className="grid items-center gap-3 border-t border-border px-3 py-3 text-sm"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<label htmlFor={inputId} className="font-medium text-gray-700">
@@ -389,7 +414,7 @@ export function ExmnResultRegisterPopupPage({
</section>
{validation && (
<section className="rounded border border-[#d7e0e8] p-4">
<section className="border border-border p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-gray-700">데이터 검토 결과</h2>
@@ -416,7 +441,7 @@ export function ExmnResultRegisterPopupPage({
)}
{uploadStatus && <p className="text-right text-sm text-blue-700">{uploadStatus}</p>}
</main>
</div>
</section>
</main>
);
}
@@ -19,6 +19,7 @@ import type {
ExmnJobType,
ExmnSectionSummaryRow,
} from "../model/exmn-business-types";
import type { ExmnDetailDefectType } from "../model/exmn-detail-table";
import { getExmnSampleSectionSummaries } from "../model/exmn-sample-data";
const ACTION_BUTTON_CLASS =
@@ -93,6 +94,7 @@ const sortSummaryRows = (
};
interface DisplaySummaryRow extends Record<string, string | number> {
trtdstId: string;
rowNumber: string | number;
trtdstNm: string;
trgtPrlgText: string;
@@ -108,6 +110,8 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
const [jobType, setJobType] = useState<ExmnJobType>("PPL");
const [summarySort, setSummarySort] = useState<UiTableSort>();
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
/** 상세보기 팝업에 전달할 현재 선택 처리분구다. */
const [selectedTrtdstId, setSelectedTrtdstId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const summaryRequestIdRef = useRef(0);
@@ -115,11 +119,13 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
useEffect(() => {
setJobType("PPL");
setSummarySort(undefined);
setSelectedTrtdstId(null);
setNotice(null);
}, [business?.exmnBizId]);
useEffect(() => {
setSummarySort(undefined);
setSelectedTrtdstId(null);
}, [jobType]);
useEffect(() => {
@@ -183,6 +189,7 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
const rows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
const counts = getCounts(row);
return {
trtdstId: row.trtdstId,
rowNumber: index + 1,
trtdstNm: row.trtdstNm,
trgtPrlgText: formatNumber(row.trgtPrlg),
@@ -207,6 +214,9 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
}, [jobType, summaryRows, summarySort]);
const summaryColumns = jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
const selectedSummaryRow = summaryTable.rows.find(
(row) => row.trtdstId === selectedTrtdstId,
) ?? null;
/** 상세현황 헤더를 오름차순·내림차순·기본 순서로 순환한다. */
const handleSummarySort = (column: (typeof summaryColumns)[number]) => {
@@ -227,12 +237,18 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
setNotice({ type: "info", text });
};
/** 선택 사업과 현재 관로·맨홀 탭을 공통 WindowPopup 상세표에 전달한다. */
const openSurveyDetailPopup = () => {
if (!business) return;
/** 선택 사업·처리분구, 조사유형, 결함 범위를 공통 WindowPopup 상세표에 전달한다. */
const openSurveyDetailPopup = (
summaryRow: DisplaySummaryRow | null = selectedSummaryRow,
defectType: ExmnDetailDefectType = "ALL",
) => {
if (!business || !summaryRow) return;
const popup = openWindowPopup("exmnSurveyDetail", {
exmnBizId: business.exmnBizId,
trtdstId: summaryRow.trtdstId,
trtdstNm: summaryRow.trtdstNm,
jobType,
defectType,
bizYr: business.bizYr,
bizCycl: business.bizCycl,
bizNm: business.bizNm,
@@ -252,6 +268,12 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
}
};
/** 사업목록과 동일하게 처리분구 행의 어느 셀을 눌러도 상세 대상 행으로 선택한다. */
const handleSummaryRowSelect = (row: DisplaySummaryRow) => {
setSelectedTrtdstId(row.trtdstId);
setNotice(null);
};
/** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */
const handleJobTypeChange = (nextJobType: ExmnJobType) => {
if (nextJobType === jobType) return;
@@ -292,7 +314,15 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
))}
</div>
<div className="ml-auto flex items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={openSurveyDetailPopup}>상세보기</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
disabled={!selectedSummaryRow}
title={selectedSummaryRow ? `${selectedSummaryRow.trtdstNm} 상세보기` : "처리분구 행을 먼저 선택해주세요."}
onClick={() => openSurveyDetailPopup()}
>
상세보기
</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("지도보기")}>지도보기</Button>
<Button
color="green"
@@ -417,26 +447,79 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
</TableCell>
))}
</TableRow>
{summaryTable.rows.map((row, rowIndex) => (
<TableRow
key={`${row.trtdstNm}-${rowIndex}`}
style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}
>
{summaryColumns.map((column) => (
<TableCell
key={`${rowIndex}-${column.id}`}
className="!py-[0.8rem] text-center"
style={
rowIndex === summaryTable.rows.length - 1
? { borderBottomWidth: 0 }
: undefined
}
>
{row[column.id]}
</TableCell>
))}
</TableRow>
))}
{summaryTable.rows.map((row, rowIndex) => {
const selected = row.trtdstId === selectedTrtdstId;
return (
<TableRow
key={row.trtdstId}
data-state={selected ? "selected" : undefined}
className="cursor-pointer"
style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}
onClick={() => handleSummaryRowSelect(row)}
>
{summaryColumns.map((column) => {
const defectType: ExmnDetailDefectType | null =
column.id === "structuralCountText"
? "STRUCTURAL"
: column.id === "operationalCountText"
? "OPERATIONAL"
: null;
const defectCount = defectType
? Number(
String(row[column.id] ?? "0").replaceAll(",", ""),
)
: 0;
const opensFilteredDetail =
defectType !== null && defectCount > 0;
return (
<TableCell
key={`${row.trtdstId}-${column.id}`}
className="!p-0 text-center"
style={
rowIndex === summaryTable.rows.length - 1
? { borderBottomWidth: 0 }
: undefined
}
>
<button
type="button"
className={`flex w-full cursor-pointer items-center justify-center px-2 py-[0.8rem] text-[1.3rem] ${
opensFilteredDetail
? "font-semibold text-blue-600 underline underline-offset-4 hover:text-blue-800"
: selected
? "font-bold"
: "font-normal"
}`}
style={{ minHeight: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}
aria-label={
opensFilteredDetail
? `${row.trtdstNm} ${
defectType === "STRUCTURAL" ? "구조적" : "운영적"
} 결함 상세보기`
: undefined
}
title={
opensFilteredDetail
? "해당 결함만 상세보기"
: undefined
}
onClick={(event) => {
event.stopPropagation();
handleSummaryRowSelect(row);
if (opensFilteredDetail && defectType) {
openSurveyDetailPopup(row, defectType);
}
}}
>
{row[column.id]}
</button>
</TableCell>
);
})}
</TableRow>
);
})}
</>
) : (
<TableRow>
@@ -7,7 +7,10 @@ import type { ExmnBusiness, ExmnJobType } from "../model/exmn-business-types";
import {
EXMN_DETAIL_SEVERITIES,
EXMN_DETAIL_TABLE_DEFINITIONS,
type ExmnDetailDefectColumn,
type ExmnDetailDefectType,
type ExmnDetailSeverity,
type ExmnSeverityCounts,
type ExmnSurveyDetailRow,
} from "../model/exmn-detail-table";
import { getExmnSampleSurveyDetails } from "../model/exmn-sample-data";
@@ -24,8 +27,11 @@ const DETAIL_SCOPE_LABELS: Record<ExmnJobType, string> = {
MNHL: "맨홀",
};
/** 백엔드 처리분구 목록 연동 전 화면 검증에 사용하는 처리분구 선택값이다. */
const SAMPLE_DISTRICT_NAME = "덕풍";
const DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
ALL: "전체",
STRUCTURAL: "구조적결함",
OPERATIONAL: "운영적결함",
};
const FIXED_COLUMNS = [
{ id: "reportNo", label: "보고서\n번호", width: 118 },
@@ -37,8 +43,6 @@ const FIXED_COLUMNS = [
{ id: "specification", label: "규격", width: 82 },
{ id: "extensionM", label: "연장", width: 86 },
{ id: "drivingDistanceM", label: "주행\n거리", width: 92 },
{ id: "structuralInternalDefect", label: "구조적\n내부결함", width: 105 },
{ id: "operationalInternalDefect", label: "운영적\n내부결함", width: 105 },
] as const;
const REPORT_COLUMN_LEFT = 0;
@@ -62,7 +66,10 @@ type ExmnDetailFixedColumnId = (typeof FIXED_COLUMNS)[number]["id"];
interface ExmnSurveyDetailPopupPageProps {
business: ExmnBusiness;
trtdstId: string;
trtdstNm: string;
jobType: ExmnJobType;
defectType: ExmnDetailDefectType;
sampleMode: boolean;
}
@@ -102,9 +109,26 @@ const compareFixedColumnValue = (
});
};
/** 현재 화면에 노출하는 결함 열만 합산해 행의 대·중·소 합계를 계산한다. */
const getVisibleAnomalyTotals = (
row: ExmnSurveyDetailRow,
defectColumns: ExmnDetailDefectColumn[],
): ExmnSeverityCounts => {
const totals: ExmnSeverityCounts = { L: 0, M: 0, S: 0 };
defectColumns.forEach((column) => {
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
totals[severity] += row.defectCounts[column.code]?.[severity] ?? 0;
});
});
return totals;
};
/** 엑셀 `01. 덕풍`·`01. 덕풍 (2)` 구조를 관로·맨홀 공통 WindowPopup으로 표시한다. */
export function ExmnSurveyDetailPopupPage({
trtdstId,
trtdstNm,
jobType,
defectType,
sampleMode,
}: ExmnSurveyDetailPopupPageProps) {
/** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */
@@ -117,20 +141,39 @@ export function ExmnSurveyDetailPopupPage({
/** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */
const [notice, setNotice] = useState<Notice>(null);
const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
const defectTypeLabel = DEFECT_TYPE_LABELS[defectType];
/** 건수 링크 진입 시 선택한 구조·운영 그룹의 결함 열만 표에 남긴다. */
const visibleDefectColumns = useMemo(
() => defectType === "ALL"
? definition.defectColumns
: definition.defectColumns.filter((column) => column.group === defectType),
[defectType, definition],
);
/** 샘플 모드에서 선택 조사유형에 해당하는 상세 원본 행을 제공한다. */
const sourceRows = useMemo(
() => (sampleMode ? getExmnSampleSurveyDetails(jobType) : []),
[jobType, sampleMode],
);
/** 분류별 상세에서는 선택 결함이 한 건 이상 존재하는 시설 행만 표시한다. */
const scopedRows = useMemo(() => {
if (defectType === "ALL") return sourceRows;
return sourceRows.filter((row) =>
visibleDefectColumns.some((column) =>
EXMN_DETAIL_SEVERITIES.some(
(severity) => (row.defectCounts[column.code]?.[severity] ?? 0) > 0,
),
),
);
}, [defectType, sourceRows, visibleDefectColumns]);
/** 조회 버튼으로 확정한 번호 조건을 현재 상세 행에 적용한다. */
const filteredRows = useMemo(() => {
const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR");
if (!normalizedKeyword) return sourceRows;
return sourceRows.filter((row) =>
if (!normalizedKeyword) return scopedRows;
return scopedRows.filter((row) =>
row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
);
}, [appliedDetailNumberKeyword, sourceRows]);
/** 보고서번호부터 운영적 내부결함까지의 고정 열에 선택 정렬을 적용한다. */
}, [appliedDetailNumberKeyword, scopedRows]);
/** 보고서번호부터 주행거리까지의 고정 열에 선택 정렬을 적용한다. */
const rows = useMemo(() => {
if (!sort) return filteredRows;
const columnId = sort.key as ExmnDetailFixedColumnId;
@@ -155,13 +198,13 @@ export function ExmnSurveyDetailPopupPage({
};
}, []);
/** 관로·맨홀 조사유형이 바뀌면 이전 번호 검색 조건과 안내를 초기화한다. */
/** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */
useEffect(() => {
setDetailNumberKeyword("");
setAppliedDetailNumberKeyword("");
setSort(undefined);
setNotice(null);
}, [jobType]);
}, [defectType, jobType]);
/** 화면 조회 버튼으로 입력 번호를 확정해 샘플 상세 행을 필터링한다. */
const handleSearch = () => {
@@ -207,55 +250,39 @@ export function ExmnSurveyDetailPopupPage({
(sum, row) => ({
extensionM: sum.extensionM + row.extensionM,
drivingDistanceM: sum.drivingDistanceM + row.drivingDistanceM,
structuralInternalDefect:
sum.structuralInternalDefect + row.structuralInternalDefect,
operationalInternalDefect:
sum.operationalInternalDefect + row.operationalInternalDefect,
}),
{
extensionM: 0,
drivingDistanceM: 0,
structuralInternalDefect: 0,
operationalInternalDefect: 0,
},
),
[rows],
);
/** 고정 열 중 합산 의미가 있는 연장·주행거리·내부결함 4개 열의 요약 표시값이다. */
/** 고정 열 중 합산 의미가 있는 연장·주행거리 2개 열의 요약 표시값이다. */
const fixedSummaryValues = [
{ key: "extensionM", value: formatNumber(totals.extensionM) },
{ key: "drivingDistanceM", value: formatNumber(totals.drivingDistanceM) },
{
key: "structuralInternalDefect",
value: formatCount(totals.structuralInternalDefect),
},
{
key: "operationalInternalDefect",
value: formatCount(totals.operationalInternalDefect),
},
];
/** 현재 조회 결과를 기준으로 관로·맨홀 이상항목의 대·중·소 소계와 항목별 합계를 계산한다. */
/** 현재 보이는 구조·운영 결함 열만 기준으로 대·중·소 소계와 항목별 합계를 계산한다. */
const defectSummary = useMemo(() => {
const anomalySubtotals = { L: 0, M: 0, S: 0 };
const defectSubtotals = Object.fromEntries(
definition.defectColumns.map((column) => [
visibleDefectColumns.map((column) => [
column.code,
{ L: 0, M: 0, S: 0 },
]),
) as Record<string, Record<ExmnDetailSeverity, number>>;
rows.forEach((row) => {
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
anomalySubtotals[severity] += row.anomalyTotals[severity] ?? 0;
});
definition.defectColumns.forEach((column) => {
visibleDefectColumns.forEach((column) => {
const columnCounts = defectSubtotals[column.code];
if (!columnCounts) return;
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
columnCounts[severity] +=
row.defectCounts[column.code]?.[severity] ?? 0;
const count = row.defectCounts[column.code]?.[severity] ?? 0;
columnCounts[severity] += count;
anomalySubtotals[severity] += count;
});
});
});
@@ -271,7 +298,7 @@ export function ExmnSurveyDetailPopupPage({
anomalyTotal: sumSeverities(anomalySubtotals),
defectSubtotals,
defectTotals: Object.fromEntries(
definition.defectColumns.map((column) => [
visibleDefectColumns.map((column) => [
column.code,
sumSeverities(
defectSubtotals[column.code] ?? { L: 0, M: 0, S: 0 },
@@ -279,20 +306,29 @@ export function ExmnSurveyDetailPopupPage({
]),
) as Record<string, number>,
};
}, [definition.defectColumns, rows]);
}, [rows, visibleDefectColumns]);
const tableWidth = useMemo(
() => FIXED_COLUMNS.reduce((sum, column) => sum + column.width, 0)
+ (definition.defectColumns.length + 1)
+ (visibleDefectColumns.length + 1)
* EXMN_DETAIL_SEVERITIES.length
* SEVERITY_COLUMN_WIDTH,
[definition.defectColumns.length],
[visibleDefectColumns.length],
);
const popupTitle = defectType === "ALL"
? definition.title
: `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`;
const tableTitle = defectType === "ALL"
? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표`
: `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`;
const visibleDefectGroupTitle = defectType === "ALL"
? definition.defectGroupTitle
: `${definition.defectGroupTitle} ${defectTypeLabel}`;
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">{definition.title}</h1>
<h1 className="sn-popup-title">{popupTitle}</h1>
</header>
{notice && (
@@ -310,10 +346,10 @@ export function ExmnSurveyDetailPopupPage({
<div className="sn-popup-field-label">처리분구</div>
<select
className="sn-popup-input !text-[1.1rem]"
defaultValue={SAMPLE_DISTRICT_NAME}
defaultValue={trtdstId}
aria-label="처리분구"
>
<option value={SAMPLE_DISTRICT_NAME}>{SAMPLE_DISTRICT_NAME}</option>
<option value={trtdstId}>{trtdstNm}</option>
</select>
</label>
@@ -324,7 +360,10 @@ export function ExmnSurveyDetailPopupPage({
defaultValue={jobType}
aria-label="조사대상"
>
<option value={jobType}>{DETAIL_SCOPE_LABELS[jobType]}</option>
<option value={jobType}>
{DETAIL_SCOPE_LABELS[jobType]}
{defectType === "ALL" ? "" : ` / ${defectTypeLabel}`}
</option>
</select>
</label>
@@ -374,7 +413,7 @@ export function ExmnSurveyDetailPopupPage({
<div className="flex shrink-0 items-center justify-between gap-4 py-3">
<div className="flex min-w-0 items-baseline gap-3">
<h2 className="shrink-0 text-[1.25rem] font-semibold text-title">
{SAMPLE_DISTRICT_NAME} 처리분구 {definition.defectGroupTitle}조사 집계표
{tableTitle}
</h2>
<span className="truncate text-[1.05rem] text-label">
조회 결과 총 {rows.length.toLocaleString("ko-KR")}건
@@ -399,7 +438,7 @@ export function ExmnSurveyDetailPopupPage({
<col key={column.id} style={{ width: `${column.width}px` }} />
))}
{Array.from({
length: (definition.defectColumns.length + 1)
length: (visibleDefectColumns.length + 1)
* EXMN_DETAIL_SEVERITIES.length,
}).map((_, index) => (
<col key={`severity-col-${index}`} style={{ width: `${SEVERITY_COLUMN_WIDTH}px` }} />
@@ -410,6 +449,10 @@ export function ExmnSurveyDetailPopupPage({
{FIXED_COLUMNS.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
const direction = sort?.key === column.id ? sort.direction : undefined;
const columnLabel =
column.id === "pipeNo" && jobType === "MNHL"
? "맨홀\n번호"
: column.label;
return (
<th
key={column.id}
@@ -435,10 +478,10 @@ export function ExmnSurveyDetailPopupPage({
<button
type="button"
className="inline-flex h-full w-full items-center justify-center gap-1.5"
aria-label={`${column.label.replace("\n", " ")} 정렬`}
aria-label={`${columnLabel.replace("\n", " ")} 정렬`}
onClick={() => handleSortChange(column.id)}
>
<span className="whitespace-pre-line">{column.label}</span>
<span className="whitespace-pre-line">{columnLabel}</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
@@ -467,7 +510,7 @@ export function ExmnSurveyDetailPopupPage({
);
})}
<th
colSpan={(definition.defectColumns.length + 1) * EXMN_DETAIL_SEVERITIES.length}
colSpan={(visibleDefectColumns.length + 1) * EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
이상항목
@@ -481,10 +524,10 @@ export function ExmnSurveyDetailPopupPage({
합계
</th>
<th
colSpan={definition.defectColumns.length * EXMN_DETAIL_SEVERITIES.length}
colSpan={visibleDefectColumns.length * EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
{definition.defectGroupTitle}
{visibleDefectGroupTitle}
</th>
</tr>
<tr>
@@ -497,19 +540,19 @@ export function ExmnSurveyDetailPopupPage({
{SEVERITY_LABELS[severity]}
</th>
))}
{definition.defectColumns.map((column) => (
{visibleDefectColumns.map((column) => (
<th
key={column.code}
colSpan={EXMN_DETAIL_SEVERITIES.length}
title={`${column.label} (${column.code})`}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2 whitespace-nowrap"
>
{column.label}
{column.label} ({column.code})
</th>
))}
</tr>
<tr>
{definition.defectColumns.flatMap((column) =>
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`${column.code}-${severity}`}
@@ -524,8 +567,13 @@ export function ExmnSurveyDetailPopupPage({
<tbody>
{rows.length > 0 ? (
<>
{rows.map((row) => (
<tr key={row.reportNo} className="sn-popup-grid-row sn-popup-grid-row-hover">
{rows.map((row) => {
const visibleAnomalyTotals = getVisibleAnomalyTotals(
row,
visibleDefectColumns,
);
return (
<tr key={row.reportNo} className="sn-popup-grid-row sn-popup-grid-row-hover">
{FIXED_COLUMNS.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
return (
@@ -552,10 +600,10 @@ export function ExmnSurveyDetailPopupPage({
key={`${row.reportNo}-total-${severity}`}
className="border-b border-r border-border bg-background px-2 py-2.5"
>
{formatCount(row.anomalyTotals[severity])}
{formatCount(visibleAnomalyTotals[severity])}
</td>
))}
{definition.defectColumns.flatMap((column) =>
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-${column.code}-${severity}`}
@@ -565,8 +613,9 @@ export function ExmnSurveyDetailPopupPage({
</td>
)),
)}
</tr>
))}
</tr>
);
})}
<tr className="bg-background font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
@@ -607,7 +656,7 @@ export function ExmnSurveyDetailPopupPage({
{formatCount(defectSummary.anomalySubtotals[severity])}
</td>
))}
{definition.defectColumns.flatMap((column) =>
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-${column.code}-${severity}`}
@@ -658,7 +707,7 @@ export function ExmnSurveyDetailPopupPage({
>
{formatCount(defectSummary.anomalyTotal)}
</td>
{definition.defectColumns.map((column) => (
{visibleDefectColumns.map((column) => (
<td
key={`total-${column.code}`}
colSpan={EXMN_DETAIL_SEVERITIES.length}
@@ -673,7 +722,7 @@ export function ExmnSurveyDetailPopupPage({
<tr>
<td
colSpan={FIXED_COLUMNS.length
+ (definition.defectColumns.length + 1)
+ (visibleDefectColumns.length + 1)
* EXMN_DETAIL_SEVERITIES.length}
className="sn-popup-empty-cell h-28 border-b border-r border-border text-center"
>
@@ -19,7 +19,7 @@ export const windowPopupDefinitions = {
title: "조사 상세현황",
width: 1500,
height: 720,
requiredParams: ["exmnBizId", "jobType"],
requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"],
},
oldPpUpload: {
key: "oldPpUpload",
@@ -1,6 +1,7 @@
"use client";
import type { ExmnBusiness, ExmnJobType } from "@/features/exmn/model/exmn-business-types";
import type { ExmnDetailDefectType } from "@/features/exmn/model/exmn-detail-table";
import { ExmnSurveyDetailPopupPage } from "@/features/exmn/ui/ExmnSurveyDetailPopupPage";
import { useEffect, useState } from "react";
import type { WindowPopupContentProps } from "../model/types";
@@ -13,6 +14,16 @@ const getParam = (value: string | string[] | undefined) =>
const getJobType = (value: string | string[] | undefined): ExmnJobType =>
getParam(value) === "MNHL" ? "MNHL" : "PPL";
/** 결함 건수 링크가 전달한 구조·운영 구분을 복원하며 직접 진입은 통합 보기로 처리한다. */
const getDefectType = (
value: string | string[] | undefined,
): ExmnDetailDefectType => {
const defectType = getParam(value);
return defectType === "STRUCTURAL" || defectType === "OPERATIONAL"
? defectType
: "ALL";
};
/** 부모 조사사업 목록의 선택 행 정보를 팝업 상단 표시 계약으로 복원한다. */
const buildBusinessSummary = (
searchParams: WindowPopupContentProps["searchParams"],
@@ -46,7 +57,10 @@ export default function ExmnSurveyDetailPopupContent({
return (
<ExmnSurveyDetailPopupPage
business={buildBusinessSummary(searchParams)}
trtdstId={getParam(searchParams.trtdstId)}
trtdstNm={getParam(searchParams.trtdstNm)}
jobType={getJobType(searchParams.jobType)}
defectType={getDefectType(searchParams.defectType)}
sampleMode={getParam(searchParams.sample) === "Y"}
/>
);