Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
This commit is contained in:
@@ -180,3 +180,22 @@
|
||||
|
||||
- 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register?sample=Y` 진입이 선행조건이다.
|
||||
- 개발 서버가 실행 중이면 별도 프로덕션 빌드를 실행하지 않고 정적 검사와 사용자 화면 확인을 우선한다.
|
||||
|
||||
## 14. 관로 본단위구간과 결함별 상세 팝업 회귀 계획
|
||||
|
||||
| 검증 항목 | 방법 | 기대 결과 |
|
||||
|---|---|---|
|
||||
| 관로 전체 상세보기 | `PPL`, `defectType=ALL` 진입 | 처리분구·관리번호와 `관로`·`본단위구간` 전환 표시 |
|
||||
| 맨홀 상세보기 | `MNHL` 상세보기 진입 | 관로 전용 모드 전환 미표시 |
|
||||
| 본단위구간 초기값 | 관리번호 없이 `본단위구간` 전환 | 선택 처리분구의 전체 관리번호 단위구간 표시 |
|
||||
| 관리번호 검색 | 일부 관리번호 입력 후 조회 | 부분 일치 관로번호의 단위구간만 표시 |
|
||||
| 단위구간 표 구조 | 한 관로의 여러 상세행 확인 | 관로·단위구간 공통 셀 병합과 14개 열 유지 |
|
||||
| 구조적 링크 | 구조적결함 건수 클릭 | 조회 조건 없이 구조적결함 집계표 즉시 표시 |
|
||||
| 운영적 링크 | 운영적결함 건수 클릭 | 조회 조건 없이 운영적결함 집계표 즉시 표시 |
|
||||
| 결함별 요약 행 | 집계표 제목 행 확인 | 합계 정보와 다운로드 버튼이 오른쪽에 함께 표시 |
|
||||
| 전체 상세보기 회귀 | 상단 상세보기 버튼 진입 | 기존 조회 조건·모드·전체 이상항목 표 유지 |
|
||||
| 엑셀 다운로드 | 전체·분류별·본단위구간 각각 다운로드 | 현재 표시 행·열, 소계·합계와 병합 구조 반영 |
|
||||
| 정적 검사 | 변경 저장소에서 `git diff --check` | 공백 오류 없음 |
|
||||
|
||||
- 실제 다운로드 파일은 Excel에서 열어 헤더, 병합 범위, 소계·합계와 한글 파일명을 대조한다.
|
||||
- 실제 API 연결 후에는 샘플과 동일한 조건으로 JSONB 코드·점수·단위구간 매핑을 재검증한다.
|
||||
|
||||
@@ -206,3 +206,21 @@
|
||||
## 14. 흡수한 기존 문서
|
||||
|
||||
- 없음
|
||||
|
||||
## 15. 관로 본단위구간과 결함별 상세 팝업 검증 결과
|
||||
|
||||
### 15.1 확인 결과
|
||||
|
||||
- 관로 전체 상세보기에서 `관로`·`본단위구간` 전환 UI가 표시되고, 맨홀 상세보기에서는 표시되지 않는 분기를 소스로 확인했다.
|
||||
- 본단위구간 초기 진입 시 3개 샘플 관로의 33개 상세 레코드를 표시하고 관리번호 입력 시 부분 일치로 거르는 동작을 확인했다.
|
||||
- 구조적·운영적 결함 건수 링크 팝업에서 조회 조건 영역이 제거되고 제목, 조회 건수, 합계 정보와 다운로드가 한 행에 표시되는 화면을 사용자가 확인해 완료 판정했다.
|
||||
- `defectType=ALL`과 `defectType=STRUCTURAL` 팝업 경로가 로컬 개발 서버에서 각각 HTTP 200으로 응답하는 것을 확인했다.
|
||||
- 다운로드 구현이 현재 정렬·필터 결과와 표시 열을 전달하고 전체·분류별 표의 소계·합계 및 본단위구간 14개 열을 생성하는 것을 소스로 확인했다.
|
||||
- 변경 파일에 대한 `git diff --check`를 통과했다.
|
||||
|
||||
### 15.2 미수행·후속 검증
|
||||
|
||||
- 생성된 실제 `.xlsx` 파일을 Excel에서 열어 병합 범위와 소계·합계 값을 대조하는 검증은 아직 수행하지 않았다.
|
||||
- 로컬 TypeScript compiler는 기존 샌드박스 `EPERM` 이력에 따라 별도 실행하지 않았다.
|
||||
- 실제 서버 JSONB 데이터 연결과 코드·점수·단위구간 매핑 검증은 백엔드 연동 단계의 후속 항목이다.
|
||||
- 지도보기는 보류 범위로 이번 검증 대상에 포함하지 않았다.
|
||||
|
||||
@@ -206,3 +206,21 @@
|
||||
- 수정 대상은 `src/features/exmn/**`와 `src/features/windowPopup/**` 안으로 제한한다.
|
||||
- 백엔드 API·DTO·DB는 수정하지 않는다.
|
||||
- 완료 게이트는 관로·맨홀 각각 `ALL|STRUCTURAL|OPERATIONAL` 화면, 코드 헤더, 필터 결과와 소계·합계가 일치하는 것이다.
|
||||
|
||||
## 17. 관로 본단위구간과 결함별 상세 팝업 보완 범위
|
||||
|
||||
| 작업 단위 | 확정 내용 |
|
||||
|---|---|
|
||||
| 관로 전체 상세보기 | `관로`·`본단위구간` 소형 슬라이드형 모드 전환 |
|
||||
| 본단위구간 초기 조회 | 관리번호가 없으면 선택 처리분구의 전체 관리번호 표시 |
|
||||
| 본단위구간 검색 | 관리번호 입력 후 부분 일치 조회 |
|
||||
| 본단위구간 표 | 엑셀 `본단위등급표` 기준 14개 열과 단위구간 병합 표시 |
|
||||
| 맨홀 상세보기 | 관로 전용 모드 선택 미표시 |
|
||||
| 결함별 상세보기 | 조회 조건·모드 영역 제거 후 선택 분류 결과 즉시 표시 |
|
||||
| 결함별 요약 행 | 제목·조회 건수와 연장/주행 합계·다운로드를 한 행에 배치 |
|
||||
| 엑셀 다운로드 | 현재 화면의 표시 행·열, 소계·합계 및 병합 구조 반영 |
|
||||
|
||||
- 수정 범위는 `src/features/exmn/model/**`와 `src/features/exmn/ui/**`로 제한한다.
|
||||
- 공통 비즈니스 컴포넌트, API, DTO, DB는 변경하지 않는다.
|
||||
- 프론트 완료 게이트는 전체 상세보기 회귀, 본단위구간 전체/검색 조회, 구조·운영 결함별 간소화 화면과 현재 화면 기준 엑셀 생성이다.
|
||||
- 실제 API·JSONB 매핑과 다운로드 파일의 업무 데이터 대조는 백엔드 연동 단계에서 수행한다.
|
||||
|
||||
@@ -353,3 +353,38 @@
|
||||
- `src/features/windowPopup/model/popupDefinitions.ts`
|
||||
- `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx`
|
||||
- 백엔드 코드·API·DTO·DB는 변경하지 않았다. 후속 API는 `jobType`, 처리분구 식별자, `defectType`과 JSONB 코드별 건수 계약을 사용해야 한다.
|
||||
|
||||
## 13. 2026-07-27 관로 본단위구간과 결함별 상세 팝업 구현
|
||||
|
||||
### 13.1 관로 전체 상세보기 모드
|
||||
|
||||
- 관로조사 전체 상세보기에서 `관로`와 `본단위구간`을 전환하는 소형 슬라이드형 선택 UI를 추가했다.
|
||||
- `본단위구간`은 관로번호, 단위구간, 단위길이, 결함위치, 결함 종류·코드·등급·발생위치·침입수가점·연속결함, 구조·운영 점수, 조사중단사유, 비고를 표시한다.
|
||||
- 관로번호와 단위구간 공통 정보는 `rowSpan`으로 병합해 엑셀 `본단위등급표`와 같은 읽기 흐름을 구성했다.
|
||||
- 관리번호가 비어 있으면 선택한 처리분구의 전체 관리번호를 표시하고, 입력 후 조회하면 부분 일치하는 관로번호만 표시한다.
|
||||
- 맨홀조사에는 관로 전용 모드 UI가 나타나지 않도록 분기했다.
|
||||
- 샘플 데이터는 3개 관로에 각 2개 단위구간, 총 33개 상세 레코드를 구성해 초기 전체 조회와 관리번호 검색을 확인할 수 있게 했다.
|
||||
|
||||
### 13.2 구조적·운영적 결함별 팝업 간소화
|
||||
|
||||
- `defectType=STRUCTURAL|OPERATIONAL`로 진입하면 처리분구·관리번호, 모드, 조회 버튼과 상단 다운로드 영역을 숨기도록 변경했다.
|
||||
- 선택한 처리분구·조사 유형·결함 분류가 적용된 집계표를 팝업 진입 즉시 표시한다.
|
||||
- 집계표 제목 행 오른쪽으로 연장거리 합계, 주행거리 합계와 녹색 다운로드 버튼을 이동했다.
|
||||
- `defectType=ALL`은 기존 조회 조건과 관로 모드를 유지해 상단 상세보기 회귀를 방지했다.
|
||||
|
||||
### 13.3 엑셀 생성
|
||||
|
||||
- 기존 공통 `downloadExcelWithKoreanHeaders`를 사용해 현재 화면의 정렬·필터 결과와 표시 열을 다운로드하도록 구현했다.
|
||||
- 관로·맨홀의 전체·구조·운영 표에는 현재 표시 열을 기준으로 소계·합계 행을 추가한다.
|
||||
- 본단위구간 표는 화면과 동일한 14개 열로 생성한다.
|
||||
- 소계·합계 고정 영역과 이상항목 대·중·소 묶음에 화면 구조에 맞는 셀 병합을 적용했다.
|
||||
- 열 수가 `Z`를 넘는 다단 표도 처리하도록 마지막 열 주소를 동적으로 계산한다.
|
||||
|
||||
### 13.4 수정 파일과 연동 경계
|
||||
|
||||
- `src/features/exmn/model/exmn-detail-table.ts`
|
||||
- `src/features/exmn/model/exmn-sample-data.ts`
|
||||
- `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx`
|
||||
- `src/features/exmn/ui/ExmnPplUnitDetailTable.tsx`
|
||||
- 백엔드 코드·API·DTO·DB와 공통 비즈니스 컴포넌트는 변경하지 않았다.
|
||||
- 후속 실제 연동에서는 기존 `PPL_UNIT_V1`, `MNHL_COUNT_V1` JSONB 값을 현재 화면 모델과 코드 기준으로 매핑한다.
|
||||
|
||||
@@ -30,6 +30,29 @@ export interface ExmnSurveyDetailRow {
|
||||
defectCounts: Record<string, ExmnSeverityCounts>;
|
||||
}
|
||||
|
||||
/** 관로 상세보기에서 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */
|
||||
export type ExmnPplDetailMode = "PIPE" | "UNIT";
|
||||
|
||||
/** `본단위등급표` 한 행에 표시하는 관로 단위구간의 상태·결함 기록이다. */
|
||||
export interface ExmnPplUnitDetailRow {
|
||||
pipeNo: string;
|
||||
unitSeq: number;
|
||||
unitLengthM: number;
|
||||
defectPositionM: number;
|
||||
defectName: string;
|
||||
code: string;
|
||||
recordType: "STATUS" | "DEFECT";
|
||||
defectType: ExmnDefectGroup | null;
|
||||
severity: ExmnDetailSeverity | null;
|
||||
occurrencePosition: string;
|
||||
infiltrationBonus: number;
|
||||
continuous: boolean | null;
|
||||
structuralScore: number | null;
|
||||
operationalScore: number | null;
|
||||
surveyStopReason: string | null;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
/** 선택 조사유형에 따라 팝업 제목과 이상항목 설정을 함께 반환하는 화면 계약이다. */
|
||||
export interface ExmnDetailTableDefinition {
|
||||
jobType: ExmnJobType;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {
|
||||
EXMN_DETAIL_SEVERITIES,
|
||||
EXMN_DETAIL_TABLE_DEFINITIONS,
|
||||
type ExmnPplUnitDetailRow,
|
||||
type ExmnSeverityCounts,
|
||||
type ExmnSurveyDetailRow,
|
||||
} from "./exmn-detail-table";
|
||||
@@ -243,3 +244,217 @@ export const getExmnSampleSurveyDetails = (
|
||||
defectCounts,
|
||||
};
|
||||
});
|
||||
|
||||
/** 엑셀 `본단위등급표`의 한 관로를 단위구간별 병합 표로 확인하는 샘플 원형이다. */
|
||||
const EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE: ExmnPplUnitDetailRow[] = [
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 0,
|
||||
defectName: "(상태)조사시작(순주행)",
|
||||
code: "IS",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 5.4,
|
||||
defectName: "(관로)변형",
|
||||
code: "DF",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "S",
|
||||
occurrencePosition: "10/2",
|
||||
infiltrationBonus: 0,
|
||||
continuous: false,
|
||||
structuralScore: 25,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "4/6",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "4/6",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 7,
|
||||
defectName: "(관로)균열원주",
|
||||
code: "CC",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "11/12",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 40,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 9.7,
|
||||
defectName: "(관로)균열원주",
|
||||
code: "CC",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "7/8",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 40,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 11.6,
|
||||
defectName: "(관로)변형",
|
||||
code: "DF",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "S",
|
||||
occurrencePosition: "10/12",
|
||||
infiltrationBonus: 0,
|
||||
continuous: false,
|
||||
structuralScore: 25,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 12,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "5/7",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 12,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 본단위구간 모드 최초 진입 시 처리분구의 관리번호 전체가 보이도록
|
||||
* 관로 상세 샘플 3건에 동일한 단위구간 원형을 적용한다.
|
||||
*/
|
||||
export const EXMN_SAMPLE_PPL_UNIT_DETAILS: ExmnPplUnitDetailRow[] =
|
||||
SAMPLE_FACILITIES.flatMap((facility) =>
|
||||
EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE.map((row) => ({
|
||||
...row,
|
||||
pipeNo: facility.pipeNo,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ExmnDetailSeverity,
|
||||
type ExmnPplUnitDetailRow,
|
||||
} from "../model/exmn-detail-table";
|
||||
|
||||
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
L: "대",
|
||||
M: "중",
|
||||
S: "소",
|
||||
};
|
||||
|
||||
const COLUMN_WIDTHS = [
|
||||
120,
|
||||
72,
|
||||
82,
|
||||
88,
|
||||
230,
|
||||
62,
|
||||
62,
|
||||
82,
|
||||
82,
|
||||
78,
|
||||
82,
|
||||
82,
|
||||
150,
|
||||
150,
|
||||
] as const;
|
||||
|
||||
interface ExmnPplUnitDetailTableProps {
|
||||
rows: ExmnPplUnitDetailRow[];
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
interface RowSpanInfo {
|
||||
firstIndex: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 연속된 동일 그룹을 찾아 엑셀의 관로번호·단위구간 병합 셀에 사용할 rowSpan을 계산한다. */
|
||||
const buildRowSpanMap = (
|
||||
rows: ExmnPplUnitDetailRow[],
|
||||
getKey: (row: ExmnPplUnitDetailRow) => string,
|
||||
) => {
|
||||
const result = new Map<string, RowSpanInfo>();
|
||||
rows.forEach((row, index) => {
|
||||
const key = getKey(row);
|
||||
const current = result.get(key);
|
||||
if (current) {
|
||||
current.count += 1;
|
||||
return;
|
||||
}
|
||||
result.set(key, { firstIndex: index, count: 1 });
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/** 숫자 0은 유지하고 미입력 점수·사유만 엑셀형 하이픈으로 표시한다. */
|
||||
const formatOptionalValue = (value: number | string | null | undefined) =>
|
||||
value == null || value === "" ? "-" : String(value);
|
||||
|
||||
/** 엑셀 `본단위등급표`의 2단 헤더와 단위별 병합 행을 상세보기 팝업에 표시한다. */
|
||||
export function ExmnPplUnitDetailTable({
|
||||
rows,
|
||||
emptyMessage,
|
||||
}: ExmnPplUnitDetailTableProps) {
|
||||
const pipeSpans = buildRowSpanMap(rows, (row) => row.pipeNo);
|
||||
const unitSpans = buildRowSpanMap(
|
||||
rows,
|
||||
(row) => `${row.pipeNo}::${row.unitSeq}`,
|
||||
);
|
||||
const tableWidth = COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto border border-border bg-background">
|
||||
<table
|
||||
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
|
||||
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
|
||||
>
|
||||
<colgroup>
|
||||
{COLUMN_WIDTHS.map((width, index) => (
|
||||
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-30 bg-[#eaf3fb] text-[1.2rem] font-medium leading-[1.2] text-[#647784]">
|
||||
<tr>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">관로번호</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">단위<br />구간</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">단위<br />길이<br />(m)</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">결함<br />위치<br />(m)</th>
|
||||
<th colSpan={6} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">결함</th>
|
||||
<th colSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">등급점수(점)</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">조사중단사유</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">비고</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">종류</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">코드</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">등급</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">발생<br />위치</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">침입수<br />가점</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">연속<br />결함</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">구조적</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">운영적</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length > 0 ? rows.map((row, index) => {
|
||||
const pipeSpan = pipeSpans.get(row.pipeNo);
|
||||
const unitKey = `${row.pipeNo}::${row.unitSeq}`;
|
||||
const unitSpan = unitSpans.get(unitKey);
|
||||
const showPipeCell = pipeSpan?.firstIndex === index;
|
||||
const showUnitCells = unitSpan?.firstIndex === index;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
|
||||
className="sn-popup-grid-row sn-popup-grid-row-hover"
|
||||
>
|
||||
{showPipeCell && (
|
||||
<td
|
||||
rowSpan={pipeSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium text-primary"
|
||||
>
|
||||
{row.pipeNo}
|
||||
</td>
|
||||
)}
|
||||
{showUnitCells && (
|
||||
<>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium"
|
||||
>
|
||||
{row.unitSeq}
|
||||
</td>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{row.unitLengthM}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.defectPositionM}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5 text-left">{row.defectName}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.code}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.occurrencePosition)}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.infiltrationBonus}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.structuralScore)}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.operationalScore)}</td>
|
||||
{showUnitCells && (
|
||||
<>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{formatOptionalValue(row.surveyStopReason)}
|
||||
</td>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{formatOptionalValue(row.remark)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={COLUMN_WIDTHS.length}
|
||||
className="sn-popup-empty-cell h-28 border-b border-r border-border text-center"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import {
|
||||
downloadExcelWithKoreanHeaders,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components/ui";
|
||||
import { Button, Input } from "@common/components/ui";
|
||||
import { useEffect, useMemo, useState, type UIEvent } from "react";
|
||||
import type { ExmnBusiness, ExmnJobType } from "../model/exmn-business-types";
|
||||
@@ -10,10 +13,15 @@ import {
|
||||
type ExmnDetailDefectColumn,
|
||||
type ExmnDetailDefectType,
|
||||
type ExmnDetailSeverity,
|
||||
type ExmnPplDetailMode,
|
||||
type ExmnSeverityCounts,
|
||||
type ExmnSurveyDetailRow,
|
||||
} from "../model/exmn-detail-table";
|
||||
import { getExmnSampleSurveyDetails } from "../model/exmn-sample-data";
|
||||
import {
|
||||
EXMN_SAMPLE_PPL_UNIT_DETAILS,
|
||||
getExmnSampleSurveyDetails,
|
||||
} from "../model/exmn-sample-data";
|
||||
import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable";
|
||||
|
||||
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
L: "대",
|
||||
@@ -21,10 +29,10 @@ const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
S: "소",
|
||||
};
|
||||
|
||||
/** 상세보기 상단 조회 조건의 조사대상 표시 문구다. */
|
||||
const DETAIL_SCOPE_LABELS: Record<ExmnJobType, string> = {
|
||||
PPL: "관로 / 본단위구간",
|
||||
MNHL: "맨홀",
|
||||
/** 관로 상세보기에서 조회 결과 표를 전환하는 모드 표시 문구다. */
|
||||
const PPL_DETAIL_MODE_LABELS: Record<ExmnPplDetailMode, string> = {
|
||||
PIPE: "관로",
|
||||
UNIT: "본단위구간",
|
||||
};
|
||||
|
||||
const DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
|
||||
@@ -83,6 +91,18 @@ const formatNumber = (value: number) =>
|
||||
const formatCount = (value: number | null | undefined) =>
|
||||
Number(value ?? 0) === 0 ? "-" : Number(value).toLocaleString("ko-KR");
|
||||
|
||||
/** 가변 결함 열 수에 맞춰 엑셀의 마지막 열 이름(A, Z, AA 등)을 계산한다. */
|
||||
const getExcelColumnName = (columnCount: number) => {
|
||||
let remaining = columnCount;
|
||||
let result = "";
|
||||
while (remaining > 0) {
|
||||
remaining -= 1;
|
||||
result = String.fromCharCode(65 + (remaining % 26)) + result;
|
||||
remaining = Math.floor(remaining / 26);
|
||||
}
|
||||
return result || "A";
|
||||
};
|
||||
|
||||
/** 공통 고정 열의 행 값을 화면용 문자열로 변환한다. */
|
||||
const getFixedCellValue = (
|
||||
row: ExmnSurveyDetailRow,
|
||||
@@ -131,6 +151,8 @@ export function ExmnSurveyDetailPopupPage({
|
||||
defectType,
|
||||
sampleMode,
|
||||
}: ExmnSurveyDetailPopupPageProps) {
|
||||
/** 관로 상세보기의 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */
|
||||
const [detailMode, setDetailMode] = useState<ExmnPplDetailMode>("PIPE");
|
||||
/** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */
|
||||
const [detailNumberKeyword, setDetailNumberKeyword] = useState("");
|
||||
const [appliedDetailNumberKeyword, setAppliedDetailNumberKeyword] = useState("");
|
||||
@@ -142,6 +164,10 @@ export function ExmnSurveyDetailPopupPage({
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
|
||||
const defectTypeLabel = DEFECT_TYPE_LABELS[defectType];
|
||||
/** 맨홀은 별도 모드가 없으므로 항상 기존 상세 집계표를 사용한다. */
|
||||
const isUnitMode = jobType === "PPL" && detailMode === "UNIT";
|
||||
/** 결함 건수 링크 진입 화면은 조회 조건 없이 선택 분류 결과만 바로 표시한다. */
|
||||
const isDefectTypeView = defectType !== "ALL";
|
||||
/** 건수 링크 진입 시 선택한 구조·운영 그룹의 결함 열만 표에 남긴다. */
|
||||
const visibleDefectColumns = useMemo(
|
||||
() => defectType === "ALL"
|
||||
@@ -154,6 +180,11 @@ export function ExmnSurveyDetailPopupPage({
|
||||
() => (sampleMode ? getExmnSampleSurveyDetails(jobType) : []),
|
||||
[jobType, sampleMode],
|
||||
);
|
||||
/** 본단위구간은 PPL 전용이며 샘플 모드에서 엑셀 기준 단위 기록을 제공한다. */
|
||||
const sourceUnitRows = useMemo(
|
||||
() => (sampleMode && jobType === "PPL" ? EXMN_SAMPLE_PPL_UNIT_DETAILS : []),
|
||||
[jobType, sampleMode],
|
||||
);
|
||||
/** 분류별 상세에서는 선택 결함이 한 건 이상 존재하는 시설 행만 표시한다. */
|
||||
const scopedRows = useMemo(() => {
|
||||
if (defectType === "ALL") return sourceRows;
|
||||
@@ -173,6 +204,15 @@ export function ExmnSurveyDetailPopupPage({
|
||||
row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
|
||||
);
|
||||
}, [appliedDetailNumberKeyword, scopedRows]);
|
||||
/** 본단위구간 최초 진입은 전체를 표시하고, 관리번호가 있으면 부분 일치로 좁힌다. */
|
||||
const unitRows = useMemo(() => {
|
||||
if (!isUnitMode) return [];
|
||||
const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR");
|
||||
if (!normalizedKeyword) return sourceUnitRows;
|
||||
return sourceUnitRows.filter(
|
||||
(row) => row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
|
||||
);
|
||||
}, [appliedDetailNumberKeyword, isUnitMode, sourceUnitRows]);
|
||||
/** 보고서번호부터 주행거리까지의 고정 열에 선택 정렬을 적용한다. */
|
||||
const rows = useMemo(() => {
|
||||
if (!sort) return filteredRows;
|
||||
@@ -200,6 +240,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
|
||||
/** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */
|
||||
useEffect(() => {
|
||||
setDetailMode("PIPE");
|
||||
setDetailNumberKeyword("");
|
||||
setAppliedDetailNumberKeyword("");
|
||||
setSort(undefined);
|
||||
@@ -212,6 +253,17 @@ export function ExmnSurveyDetailPopupPage({
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
/** 모드를 바꾸면 이전 표의 관리번호·정렬·안내 상태를 초기화한다. */
|
||||
const handleDetailModeChange = (nextMode: ExmnPplDetailMode) => {
|
||||
if (nextMode === detailMode) return;
|
||||
setDetailMode(nextMode);
|
||||
setDetailNumberKeyword("");
|
||||
setAppliedDetailNumberKeyword("");
|
||||
setSort(undefined);
|
||||
setTableScrollLeft(0);
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
/** 공통 UiTable 규칙대로 asc, desc, 기본 순서로 고정 열 정렬을 순환한다. */
|
||||
const handleSortChange = (columnId: ExmnDetailFixedColumnId) => {
|
||||
if (sort?.key !== columnId) {
|
||||
@@ -237,14 +289,6 @@ export function ExmnSurveyDetailPopupPage({
|
||||
SUMMARY_LABEL_INITIAL_OFFSET - tableScrollLeft,
|
||||
);
|
||||
|
||||
/** 실제 엑셀 다운로드 API 연동 전 사용자에게 후속 연결 상태를 안내한다. */
|
||||
const handleDownload = () => {
|
||||
setNotice({
|
||||
type: "info",
|
||||
text: "엑셀 다운로드는 실제 상세 API 연동 단계에서 연결할 예정입니다.",
|
||||
});
|
||||
};
|
||||
|
||||
const totals = useMemo(
|
||||
() => rows.reduce(
|
||||
(sum, row) => ({
|
||||
@@ -308,6 +352,192 @@ export function ExmnSurveyDetailPopupPage({
|
||||
};
|
||||
}, [rows, visibleDefectColumns]);
|
||||
|
||||
/** 현재 화면에 표시된 관로·맨홀 상세 결과를 한글 헤더 엑셀로 내려받는다. */
|
||||
const handleDownload = async () => {
|
||||
const downloadDate = new Date().toISOString().slice(0, 10);
|
||||
const safeFilePrefix = `${trtdstNm}_${definition.defectGroupTitle}조사_${
|
||||
isUnitMode ? "본단위등급표" : defectTypeLabel
|
||||
}`.replace(/[\\/:*?"<>|]/g, "_");
|
||||
|
||||
try {
|
||||
if (isUnitMode) {
|
||||
if (unitRows.length === 0) {
|
||||
setNotice({ type: "info", text: "다운로드할 본단위구간 데이터가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const unitHeaderMapping: Record<string, string> = {
|
||||
pipeNo: "관로번호",
|
||||
unitSeq: "단위구간",
|
||||
unitLengthM: "단위길이(m)",
|
||||
defectPositionM: "결함위치(m)",
|
||||
defectName: "결함 종류",
|
||||
code: "결함 코드",
|
||||
severity: "결함 등급",
|
||||
occurrencePosition: "발생위치",
|
||||
infiltrationBonus: "침입수 가점",
|
||||
continuous: "연속결함",
|
||||
structuralScore: "구조적 등급점수",
|
||||
operationalScore: "운영적 등급점수",
|
||||
surveyStopReason: "조사중단사유",
|
||||
remark: "비고",
|
||||
};
|
||||
const unitDownloadRows = unitRows.map((row) => ({
|
||||
pipeNo: row.pipeNo,
|
||||
unitSeq: row.unitSeq,
|
||||
unitLengthM: row.unitLengthM,
|
||||
defectPositionM: row.defectPositionM,
|
||||
defectName: row.defectName,
|
||||
code: row.code,
|
||||
severity: row.severity ? SEVERITY_LABELS[row.severity] : "-",
|
||||
occurrencePosition: row.occurrencePosition,
|
||||
infiltrationBonus: row.infiltrationBonus,
|
||||
continuous: row.continuous == null ? "-" : row.continuous ? "Y" : "N",
|
||||
structuralScore: row.structuralScore ?? "-",
|
||||
operationalScore: row.operationalScore ?? "-",
|
||||
surveyStopReason: row.surveyStopReason ?? "-",
|
||||
remark: row.remark ?? "-",
|
||||
}));
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: unitDownloadRows as unknown as JSON[],
|
||||
fileName: `${safeFilePrefix}_${downloadDate}.xlsx`,
|
||||
sheetNameArray: ["본단위등급표"],
|
||||
headerRange: "A1:N1",
|
||||
headerMapping: unitHeaderMapping,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
setNotice({ type: "info", text: "다운로드할 상세현황 데이터가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const detailHeaderMapping: Record<string, string> = {
|
||||
reportNo: "보고서번호",
|
||||
pipeNo: jobType === "PPL" ? "관로번호" : "맨홀번호",
|
||||
upstreamManhole: "상류맨홀",
|
||||
downstreamManhole: "하류맨홀",
|
||||
drainageType: "배수방식",
|
||||
pipeType: "관종",
|
||||
specification: "규격",
|
||||
extensionM: "연장(m)",
|
||||
drivingDistanceM: "주행거리(m)",
|
||||
anomalyL: "이상항목 합계-대",
|
||||
anomalyM: "이상항목 합계-중",
|
||||
anomalyS: "이상항목 합계-소",
|
||||
};
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
detailHeaderMapping[`${column.code}_${severity}`] =
|
||||
`${column.label} (${column.code})-${SEVERITY_LABELS[severity]}`;
|
||||
});
|
||||
});
|
||||
|
||||
const detailDownloadRows: Record<string, string | number>[] = rows.map((row) => {
|
||||
const anomalyTotals = getVisibleAnomalyTotals(row, visibleDefectColumns);
|
||||
const result: Record<string, string | number> = {
|
||||
reportNo: row.reportNo,
|
||||
pipeNo: row.pipeNo,
|
||||
upstreamManhole: row.upstreamManhole,
|
||||
downstreamManhole: row.downstreamManhole,
|
||||
drainageType: row.drainageType,
|
||||
pipeType: row.pipeType,
|
||||
specification: row.specification,
|
||||
extensionM: row.extensionM,
|
||||
drivingDistanceM: row.drivingDistanceM,
|
||||
anomalyL: anomalyTotals.L,
|
||||
anomalyM: anomalyTotals.M,
|
||||
anomalyS: anomalyTotals.S,
|
||||
};
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
result[`${column.code}_${severity}`] =
|
||||
row.defectCounts[column.code]?.[severity] ?? 0;
|
||||
});
|
||||
});
|
||||
return result;
|
||||
});
|
||||
const subtotalRow: Record<string, string | number> = {
|
||||
reportNo: "소계",
|
||||
pipeNo: "",
|
||||
upstreamManhole: "",
|
||||
downstreamManhole: "",
|
||||
drainageType: "",
|
||||
pipeType: "",
|
||||
specification: "",
|
||||
extensionM: totals.extensionM,
|
||||
drivingDistanceM: totals.drivingDistanceM,
|
||||
anomalyL: defectSummary.anomalySubtotals.L,
|
||||
anomalyM: defectSummary.anomalySubtotals.M,
|
||||
anomalyS: defectSummary.anomalySubtotals.S,
|
||||
};
|
||||
const totalRow: Record<string, string | number> = {
|
||||
reportNo: "합계",
|
||||
pipeNo: "",
|
||||
upstreamManhole: "",
|
||||
downstreamManhole: "",
|
||||
drainageType: "",
|
||||
pipeType: "",
|
||||
specification: "",
|
||||
extensionM: totals.extensionM,
|
||||
drivingDistanceM: totals.drivingDistanceM,
|
||||
anomalyL: defectSummary.anomalyTotal,
|
||||
anomalyM: "",
|
||||
anomalyS: "",
|
||||
};
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
subtotalRow[`${column.code}_${severity}`] =
|
||||
defectSummary.defectSubtotals[column.code]?.[severity] ?? 0;
|
||||
});
|
||||
totalRow[`${column.code}_L`] =
|
||||
defectSummary.defectTotals[column.code] ?? 0;
|
||||
totalRow[`${column.code}_M`] = "";
|
||||
totalRow[`${column.code}_S`] = "";
|
||||
});
|
||||
detailDownloadRows.push(subtotalRow, totalRow);
|
||||
|
||||
const subtotalExcelRow = rows.length + 2;
|
||||
const totalExcelRow = rows.length + 3;
|
||||
const mergeRanges = [
|
||||
`A${subtotalExcelRow}:G${subtotalExcelRow}`,
|
||||
`A${totalExcelRow}:G${totalExcelRow}`,
|
||||
];
|
||||
for (
|
||||
let groupStartColumn = 10;
|
||||
groupStartColumn < Object.keys(detailHeaderMapping).length;
|
||||
groupStartColumn += EXMN_DETAIL_SEVERITIES.length
|
||||
) {
|
||||
mergeRanges.push(
|
||||
`${getExcelColumnName(groupStartColumn)}${totalExcelRow}:${
|
||||
getExcelColumnName(groupStartColumn + EXMN_DETAIL_SEVERITIES.length - 1)
|
||||
}${totalExcelRow}`,
|
||||
);
|
||||
}
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: detailDownloadRows as unknown as JSON[],
|
||||
fileName: `${safeFilePrefix}_${downloadDate}.xlsx`,
|
||||
sheetNameArray: [
|
||||
`${definition.defectGroupTitle}조사 ${defectTypeLabel}`.slice(0, 31),
|
||||
],
|
||||
headerRange: `A1:${getExcelColumnName(Object.keys(detailHeaderMapping).length)}1`,
|
||||
headerMapping: detailHeaderMapping,
|
||||
mergeRanges,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("조사 상세현황 엑셀 다운로드 오류:", error);
|
||||
setNotice({
|
||||
type: "info",
|
||||
text: error instanceof Error
|
||||
? error.message
|
||||
: "엑셀 다운로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const tableWidth = useMemo(
|
||||
() => FIXED_COLUMNS.reduce((sum, column) => sum + column.width, 0)
|
||||
+ (visibleDefectColumns.length + 1)
|
||||
@@ -315,12 +545,21 @@ export function ExmnSurveyDetailPopupPage({
|
||||
* SEVERITY_COLUMN_WIDTH,
|
||||
[visibleDefectColumns.length],
|
||||
);
|
||||
const popupTitle = defectType === "ALL"
|
||||
? definition.title
|
||||
: `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`;
|
||||
const tableTitle = defectType === "ALL"
|
||||
? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표`
|
||||
: `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`;
|
||||
/** 본단위구간 요약은 조회된 한 관로의 단위 수와 상태·결함 기록 수를 사용한다. */
|
||||
const unitSectionCount = useMemo(
|
||||
() => new Set(unitRows.map((row) => `${row.pipeNo}::${row.unitSeq}`)).size,
|
||||
[unitRows],
|
||||
);
|
||||
const popupTitle = isUnitMode
|
||||
? "관로조사 본단위구간 상세현황"
|
||||
: defectType === "ALL"
|
||||
? definition.title
|
||||
: `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`;
|
||||
const tableTitle = isUnitMode
|
||||
? `${trtdstNm} 관로조사 본단위등급표`
|
||||
: defectType === "ALL"
|
||||
? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표`
|
||||
: `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`;
|
||||
const visibleDefectGroupTitle = defectType === "ALL"
|
||||
? definition.defectGroupTitle
|
||||
: `${definition.defectGroupTitle} ${defectTypeLabel}`;
|
||||
@@ -338,73 +577,96 @@ export function ExmnSurveyDetailPopupPage({
|
||||
)}
|
||||
|
||||
<section className="sn-popup-section overflow-y-hidden">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:items-end"
|
||||
style={{ gridTemplateColumns: "180px 220px 300px minmax(0, 1fr)" }}
|
||||
>
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">처리분구</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
defaultValue={trtdstId}
|
||||
aria-label="처리분구"
|
||||
>
|
||||
<option value={trtdstId}>{trtdstNm}</option>
|
||||
</select>
|
||||
</label>
|
||||
{!isDefectTypeView && (
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 bg-slate-50 pb-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="sn-popup-field" style={{ width: "180px" }}>
|
||||
<div className="sn-popup-field-label">처리분구</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
defaultValue={trtdstId}
|
||||
aria-label="처리분구"
|
||||
>
|
||||
<option value={trtdstId}>{trtdstNm}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">조사대상</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
defaultValue={jobType}
|
||||
aria-label="조사대상"
|
||||
>
|
||||
<option value={jobType}>
|
||||
{DETAIL_SCOPE_LABELS[jobType]}
|
||||
{defectType === "ALL" ? "" : ` / ${defectTypeLabel}`}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">
|
||||
{jobType === "PPL" ? "관로번호" : "맨홀번호"}
|
||||
</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={detailNumberKeyword}
|
||||
placeholder={`${jobType === "PPL" ? "관로" : "맨홀"}번호 입력`}
|
||||
onChange={(event) => setDetailNumberKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
<label className="sn-popup-field" style={{ width: "300px" }}>
|
||||
<div className="sn-popup-field-label">관리번호</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={detailNumberKeyword}
|
||||
placeholder="관리번호 입력"
|
||||
onChange={(event) => setDetailNumberKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sampleMode && (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{jobType === "PPL" && (
|
||||
<fieldset className="sn-popup-field" style={{ width: "180px" }}>
|
||||
<div
|
||||
className="relative grid h-[2.8rem] grid-cols-2 rounded-full border border-[#cbd5e1] bg-white p-[2px] shadow-inner"
|
||||
role="group"
|
||||
aria-label="관로 상세보기 모드"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-[2px] left-[2px] top-[2px] w-[calc(50%-2px)] rounded-full bg-primary shadow-sm transition-transform duration-200 ease-out"
|
||||
style={{
|
||||
transform: detailMode === "UNIT"
|
||||
? "translateX(100%)"
|
||||
: "translateX(0)",
|
||||
}}
|
||||
/>
|
||||
{(Object.keys(PPL_DETAIL_MODE_LABELS) as ExmnPplDetailMode[]).map(
|
||||
(mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={[
|
||||
"relative z-10 rounded-full px-2 text-[0.95rem] font-semibold transition-colors duration-200",
|
||||
detailMode === mode ? "text-white" : "text-[#64748b]",
|
||||
].join(" ")}
|
||||
aria-pressed={detailMode === mode}
|
||||
onClick={() => handleDetailModeChange(mode)}
|
||||
>
|
||||
{PPL_DETAIL_MODE_LABELS[mode]}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="sn-popup-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sampleMode && !isDefectTypeView && (
|
||||
<div className="shrink-0 border-b border-border bg-secondary px-4 py-2 text-[1.05rem] text-primary">
|
||||
실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다.
|
||||
</div>
|
||||
@@ -416,19 +678,49 @@ export function ExmnSurveyDetailPopupPage({
|
||||
{tableTitle}
|
||||
</h2>
|
||||
<span className="truncate text-[1.05rem] text-label">
|
||||
조회 결과 총 {rows.length.toLocaleString("ko-KR")}건
|
||||
조회 결과 총 {(isUnitMode ? unitRows.length : rows.length).toLocaleString("ko-KR")}건
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-7 whitespace-nowrap text-[1.05rem] text-label">
|
||||
<span><strong className="mr-2 text-foreground">연장거리 합계</strong>{formatNumber(totals.extensionM)}m</span>
|
||||
<span><strong className="mr-2 text-foreground">주행거리 합계</strong>{formatNumber(totals.drivingDistanceM)}m</span>
|
||||
{isUnitMode ? (
|
||||
<>
|
||||
<span><strong className="mr-2 text-foreground">관리번호</strong>{appliedDetailNumberKeyword || "전체"}</span>
|
||||
<span><strong className="mr-2 text-foreground">단위구간</strong>{unitSectionCount.toLocaleString("ko-KR")}개</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span><strong className="mr-2 text-foreground">연장거리 합계</strong>{formatNumber(totals.extensionM)}m</span>
|
||||
<span><strong className="mr-2 text-foreground">주행거리 합계</strong>{formatNumber(totals.drivingDistanceM)}m</span>
|
||||
{isDefectTypeView && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="sn-popup-primary-button !ml-1 !text-[1.05rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-auto border border-border bg-background"
|
||||
onScroll={handleTableScroll}
|
||||
>
|
||||
{isUnitMode ? (
|
||||
<ExmnPplUnitDetailTable
|
||||
rows={unitRows}
|
||||
emptyMessage={
|
||||
appliedDetailNumberKeyword
|
||||
? "입력한 관리번호에 해당하는 본단위구간이 없습니다."
|
||||
: "조회된 본단위구간이 없습니다."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-auto border border-border bg-background"
|
||||
onScroll={handleTableScroll}
|
||||
>
|
||||
<table
|
||||
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
|
||||
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
|
||||
@@ -732,7 +1024,8 @@ export function ExmnSurveyDetailPopupPage({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user