Compare commits

...
11 changed files with 285 additions and 72 deletions
@@ -5,6 +5,7 @@
"use client"; "use client";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmn-map-types"; import type { ExmnMapFilters, ExmnMapJobType } from "./exmn-map-types";
import { EXMN_RESULT_PPL_LEGEND_COLOR } from "./exmn-map-types";
import LayerSettingCard from "../layer-setting/layer-setting-card"; import LayerSettingCard from "../layer-setting/layer-setting-card";
type Props = { type Props = {
@@ -52,7 +53,8 @@ export default function ExmnLayerSettingPanel({
<label className="flex min-h-[24px] flex-col items-stretch gap-1"> <label className="flex min-h-[24px] flex-col items-stretch gap-1">
<span className={`flex items-center gap-1 ${labelClass}`}> <span className={`flex items-center gap-1 ${labelClass}`}>
<span <span
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`} className="h-2 w-2 rounded-full"
style={{ backgroundColor: jobType === "PPL" ? EXMN_RESULT_PPL_LEGEND_COLOR : "#8b5cf6" }}
/> />
사업차수 사업차수
</span> </span>
@@ -8,7 +8,7 @@ export const EXMN_RESULT_ROOT_LAYER_TYPE = "sn_exmn_result_group";
export const EXMN_RESULT_PPL_LAYER_TYPE = "sn_exmn_result_ppl"; export const EXMN_RESULT_PPL_LAYER_TYPE = "sn_exmn_result_ppl";
export const EXMN_RESULT_MNHL_LAYER_TYPE = "sn_exmn_result_mnhl"; export const EXMN_RESULT_MNHL_LAYER_TYPE = "sn_exmn_result_mnhl";
// 레이어 트리에서 조사 관로와 기존 맨홀 분류 심볼을 즉시 구분하기 위한 전용 범례 스타일이다. // 레이어 트리에서 조사 관로와 기존 맨홀 분류 심볼을 즉시 구분하기 위한 전용 범례 스타일이다.
export const EXMN_RESULT_PPL_LEGEND_COLOR = "#facc15"; export const EXMN_RESULT_PPL_LEGEND_COLOR = "#000080";
export const EXMN_RESULT_MNHL_LEGEND_ICON = "SN_EXMN_MANHOLE_PURPLE.png?v=3"; export const EXMN_RESULT_MNHL_LEGEND_ICON = "SN_EXMN_MANHOLE_PURPLE.png?v=3";
export type ExmnMapJobType = "PPL" | "MNHL"; export type ExmnMapJobType = "PPL" | "MNHL";
@@ -4,6 +4,7 @@ import Icon from "ol/style/Icon.js";
import Stroke from "ol/style/Stroke.js"; import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js"; import Style from "ol/style/Style.js";
import Text from "ol/style/Text.js"; import Text from "ol/style/Text.js";
import { EXMN_RESULT_PPL_LEGEND_COLOR } from "../components/exmn/exmn-map-types";
import { import {
EXMN_GRADE_COLORS, EXMN_GRADE_COLORS,
EXMN_RESULT_MANHOLE_ICON_SRC, EXMN_RESULT_MANHOLE_ICON_SRC,
@@ -14,10 +15,11 @@ export const getExmnGradeColor = (feature: any) => {
return EXMN_GRADE_COLORS[grade] ?? "#2563eb"; return EXMN_GRADE_COLORS[grade] ?? "#2563eb";
}; };
export const createExmnPipeStyle = (feature: any) => // 조사 관로는 상태등급과 관계없이 범례와 같은 짙은 남색으로 표시한다.
export const createExmnPipeStyle = () =>
new Style({ new Style({
stroke: new Stroke({ stroke: new Stroke({
color: getExmnGradeColor(feature), color: EXMN_RESULT_PPL_LEGEND_COLOR,
width: 4, width: 4,
}), }),
}); });
+3 -35
View File
@@ -4,47 +4,15 @@
* 📄 Home Page * 📄 Home Page
* 홈 페이지 컴포넌트 * 홈 페이지 컴포넌트
* *
* - 쿠키 기반 인증 토큰 검증 및 자동 로그인 처리 * - 루트 진입 시 지도 페이지(/map)로 리다이렉트
* - 로그인 성공 시 지도 페이지로 리다이렉트 * - 인증 토큰 검증 및 로그인 화면 이동은 /map 및 공통 인증 흐름에서 처리
* - 개발 환경에서는 루트 진입 시 자동 로그인 후 지도 페이지 이동
* - 로그인 실패 시 에러 로깅 및 처리
* - 사용자 세션 관리 및 초기 라우팅 제어 * - 사용자 세션 관리 및 초기 라우팅 제어
* - Next.js useRouter를 통한 클라이언트 사이드 라우팅
*
* 🔗 사용 API
* - 로그인: POST /api/v1/auth/login (user-service)
*/ */
"use client"; "use client";
import { authService } from "@/entities/auth";
import { useEffect } from "react"; import { useEffect } from "react";
const DEV_HOSTNAMES = new Set(["localhost", "127.0.0.1", "con.mind-one.co.kr"]);
const DEV_LOGIN_PAYLOAD = {
userId: process.env.NEXT_PUBLIC_DEV_LOGIN_USER_ID || "CLEANWATER",
password: process.env.NEXT_PUBLIC_DEV_LOGIN_PASSWORD || "gktneh12!@",
};
export default function Home() { export default function Home() {
useEffect(() => { useEffect(() => {
const hostname = window.location.hostname; window.location.replace("/map");
const isDevHost = DEV_HOSTNAMES.has(hostname);
if (!isDevHost) {
window.location.replace("/map");
return;
}
async function doLogin() {
try {
// 개발 환경에서는 남아 있는 만료/부분 쿠키로 인한 /map <-> / 루프를 막기 위해 항상 토큰을 재발급한다.
await authService.login(DEV_LOGIN_PAYLOAD);
window.location.replace("/map");
} catch (err) {
console.error("로그인 실패", err);
}
}
void doLogin();
}, []); }, []);
} }
@@ -719,3 +719,19 @@
- 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다. - 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다.
- 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788. - 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788.
- 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다. - 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다.
## 2026-10-28 관로조사 남색 표시
- 정적 확인: 관로 선·범례·설정 표식은 `EXMN_RESULT_PPL_LEGEND_COLOR(#000080)`을 공유하고 관로 선 두께는 4를 유지한다. 맨홀·선택 이벤트·API·DB 변경 없음.
- `git -C frontend diff --check`: 통과.
- 3002 리스너 부재로 HMR·실브라우저 검증은 미수행. 실제 지도에서 관로조사 켜기, 등급 필터 변경 후 남색 유지, 선택 강조·맨홀 동시 표시 구분을 후속 확인한다.
### 2026-10-28 개발 서버 기동 후 확인
- 사용자 요청으로 `pnpm --filter sn dev` 실행, Next.js Ready 및 localhost:3002 접속 확인. 서버는 유지한다.
- Chrome에서 /map 진입 시 /auth/login으로 이동하고 로그인 화면이 표시됨. 로컬 로그인 세션이 없어 실제 지도 선·범례 가시성 검증은 로그인 후 계속해야 한다. 인증 우회·환경설정 변경 없음.
### 2026-10-28 백엔드 복구 및 실지도 검증 완료
- 원인: 프론트만 실행되어 localhost:8883 지도 API가 ECONNREFUSED로 실패했다. 기존 local 설정으로 Config Server와 sewerage-service를 기동했다. 코드·환경 파일·DB 변경 없음.
- 지도 레이어 API HTTP 200. 실제 레이어 목록·조사사업 조회 정상.
- 테스트 사업 → 관로조사 → 초이처리분구 → 지도보기: 3개 표시, 줌18.0에서 짙은 남색 관로가 일반지도 배경과 주황색 기존 관로에서 선명하게 구분됨. 이전 실지도 미검증 상태 해소. 위성지도·모든 등급·선택 강조 조합은 미검증.
- 사용자 계속 실행 지시에 따라 FE3002 PID28176, BE8883 PID31232, Config8888 PID27092 유지. 종료 시 해당 PID가 같은 프로세스인지 재확인 후 Stop-Process -Id 명령으로 종료한다.
@@ -38,7 +38,7 @@
| 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 | | 파일 | 역할 | 선언/주석 보조 근거 | 행 | 직접 참조 | 상태 |
|---|---|---|---:|---:|---| |---|---|---|---:|---:|---|
| [layout.tsx](<D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/layout.tsx>) | Next 루트 HTML·테마·메타데이터·Provider 진입점 | metadata, RootLayout / ➡️ 경로 : 모든 페이지 | 44 | 0 | 등록/도구 진입 규칙 대상 | | [layout.tsx](<D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/layout.tsx>) | Next 루트 HTML·테마·메타데이터·Provider 진입점 | metadata, RootLayout / ➡️ 경로 : 모든 페이지 | 44 | 0 | 등록/도구 진입 규칙 대상 |
| [page.tsx](<D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/page.tsx>) | 루트 진입/개발 자동로그인·지도 이동 | DEV_HOSTNAMES, DEV_LOGIN_PAYLOAD, Home / 홈 페이지 컴포넌트 | 51 | 0 | 등록/도구 진입 규칙 대상 | | [page.tsx](<D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/page.tsx>) | 루트 진입/지도 이동 | Home / 홈 페이지 컴포넌트 | 18 | 0 | 등록/도구 진입 규칙 대상 |
| [providers.tsx](<D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/providers.tsx>) | 실제 src/app/providers의 AppProvider 재수출. 현재 layout에서 사용 | | 2 | 1 | 정적 참조 있음 | | [providers.tsx](<D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/app/providers.tsx>) | 실제 src/app/providers의 AppProvider 재수출. 현재 layout에서 사용 | | 2 | 1 | 정적 참조 있음 |
## app/(auth)/auth ## app/(auth)/auth
@@ -1045,3 +1045,12 @@
- 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다. - 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다.
- 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788. - 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788.
- 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다. - 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다.
## 2026-10-28 관로조사 지도 색상 가시성 개선
- 사용자 요청에 따라 조사결과 > 관로조사의 지도 선을 상태등급별 색상에서 짙은 남색 `#000080`으로 통일했다. 선 두께 4와 등급 조회·필터는 유지한다.
- `app/(map-layout)/map/lib/analysis-layer-styles.ts`, `components/exmn/exmn-map-types.ts`, `components/exmn/exmn-layer-setting-panel.tsx`에서 지도 선·레이어 범례·설정 표식이 같은 색상 상수를 사용하도록 했다(뒤 두 경로도 같은 map 하위).
- 원본 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표`의 SFR-010-004, 화면설계서 45쪽 SN_MN040103 및 48쪽 SN_MN040107의 조사관로 지도 표출. 색상 변경은 이번 사용자 요청을 적용했다.
- 영향: SN 지도 표시 전용. 맨홀 심볼·선택 처리·상세 탭·API·DB 변경 없음.
- 검증: git diff --check 통과, 지도 스타일과 범례·설정 표식의 공통 상수 참조 확인. 3002 개발 서버 리스너가 없어 HMR·실제 배경지도 가시성은 미검증. 빌드·서버 기동/재시작은 수행하지 않았다.
- 롤백: 위 3개 파일의 이번 색상 변경만 되돌린다. DB 조치 없음.
@@ -20,3 +20,30 @@
## 수동 확인 필요 ## 수동 확인 필요
- 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인 - 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인
## 추가 작업: 확률강우량 팝업 엑셀다운로드 (2026-10-30)
### 변경 내용
- 팝업 헤더 우측(닫기 X 왼쪽)에 `엑셀다운로드` 버튼을 추가했다. 닫기 버튼과 겹치지 않도록 헤더에 `pr-14` 여백을 둔다.
- 공통 유틸 `src/shared/util/download-excel-workbook.ts`의 `downloadExcelWorkbook`을 재사용한다.
- 파일명은 `{시군구명}_확률강우량.xlsx`이며 시트는 두 개다.
- `확률강우량`: 재현기간(년) 행 × `N시간` 열. 셀은 소수 4자리로 자르지 않은 원본 숫자 값이다.
- `확률관측소 면적비율`: 확률관측소코드·관측소명·면적비율(%)과 합계(API `areaRtTotal`).
- 로딩 중·오류·표 데이터 없음 상태에서는 버튼을 비활성화한다.
- API 호출, 응답 구조, 화면 문구와 기존 표 렌더링은 변경하지 않았다.
### 이슈: 팝업 본체가 투명하게 렌더링됨
- 증상: 엑셀다운로드 버튼 추가 후 팝업 본체 배경이 투명해져 뒤쪽 목록 화면이 비쳐 보였다. 새로고침과 애니메이션 완료 후에도 재현됐다.
- 추정 원인: `DialogContent`의 배경이 `bg-[var(--dialog-bg)]`(`--dialog-bg: var(--background)`, `packages/common/assets/src/styles/base.css`)인데, 이 모달에서 해당 변수가 값을 가져오지 못한 것으로 추정한다. 정확한 근본 원인은 확인하지 못했다. 공통 패키지(`@common/components`, `@common/assets`)는 수정 범위 밖이라 변경하지 않았다.
- 조치: `PrbbtRnflDialog.tsx`의 `DialogContent` className에 `bg-background`를 추가해 배경을 직접 지정했다. 사용자가 해결을 확인했다(2026-10-30).
- 참고: 같은 증상이 다른 `DialogContent` 사용 팝업에서 보이면 공통 변수 문제일 수 있으므로 별도 보고 대상이다.
### 수정 파일
- `apps/sn/src/features/fldng-predc-prst/ui/PrbbtRnflDialog.tsx`
### 검증
- 사용자가 실제 화면에서 팝업 배경 정상 표시를 확인했다.
- 타입 체크·빌드·엑셀 파일 내용 열람은 수행하지 않았다.
### 롤백
- 위 파일의 변경(헤더 버튼, `handleExcelDownload`, 두 import, `bg-background`)을 되돌린다.
@@ -3,6 +3,10 @@ import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
export type FloodPredictionExcelRow = { export type FloodPredictionExcelRow = {
ctpvNm: string; ctpvNm: string;
sggNm: string; sggNm: string;
/** `/prst` 조회 때 시군구-관측소 매핑에서 붙인 관측소명이다. 기존 로직 행이나 매핑에 없는 조합은 null이다. */
obsrvnBrnchNm: string | null;
/** 시군구 면적 중 관측소 영역의 비율(%)이다. 기존 로직 행이나 매핑에 없는 조합은 null이다. */
areaRt: number | null;
analysisDateTime: string; analysisDateTime: string;
rainBeginningDateTime: string; rainBeginningDateTime: string;
observedAccumulatedRainfall: number | null; observedAccumulatedRainfall: number | null;
@@ -12,10 +16,13 @@ export type FloodPredictionExcelRow = {
floodRiskGrade: string; floodRiskGrade: string;
}; };
/** Excel 행 객체 키를 한글 헤더로 바꾸는 매핑이다. 실제 열 순서는 행 객체의 키 순서를 따르므로 같은 순서로 둔다. */
const HEADERS: Record<string, string> = { const HEADERS: Record<string, string> = {
rowNo: "순번", rowNo: "순번",
ctpvNm: "시도", ctpvNm: "시도",
sggNm: "시군구", sggNm: "시군구",
obsrvnBrnchNm: "관측소",
areaRt: "면적비율(%)",
analysisDateTime: "분석시각", analysisDateTime: "분석시각",
rainBeginningDateTime: "강우시작시각", rainBeginningDateTime: "강우시작시각",
observedAccumulatedRainfall: "관측 누적강수량(mm)", observedAccumulatedRainfall: "관측 누적강수량(mm)",
@@ -39,12 +46,20 @@ export const downloadFloodPredictionStatusExcel = async (
rowNo: index + 1, rowNo: index + 1,
ctpvNm: row.ctpvNm || "-", ctpvNm: row.ctpvNm || "-",
sggNm: row.sggNm || "-", sggNm: row.sggNm || "-",
// 관측소·면적비율은 시군구 뒤에 두고, 기존 로직 행이나 매핑에 없는 조합은 `-`로 내보낸다.
obsrvnBrnchNm: row.obsrvnBrnchNm || "-",
// 면적비율은 목록과 같이 소수 둘째 자리까지 반올림한 숫자로 내보낸다.
areaRt:
row.areaRt == null || !Number.isFinite(Number(row.areaRt))
? "-"
: Number(Number(row.areaRt).toFixed(2)),
analysisDateTime: formatDateTime(row.analysisDateTime), analysisDateTime: formatDateTime(row.analysisDateTime),
rainBeginningDateTime: formatDateTime(row.rainBeginningDateTime), rainBeginningDateTime: formatDateTime(row.rainBeginningDateTime),
observedAccumulatedRainfall: observedAccumulatedRainfall:
row.observedAccumulatedRainfall == null row.observedAccumulatedRainfall == null
? "-" ? "-"
: Number(row.observedAccumulatedRainfall.toFixed(1)), : Number(row.observedAccumulatedRainfall.toFixed(1)),
// 관측소 기준 행의 예측시간 0(대표값이 분석시각)도 목록과 같이 `0시간 후`로 내보낸다.
predictionHour: predictionHour:
row.predictionHour == null ? "-" : `${row.predictionHour}시간 후`, row.predictionHour == null ? "-" : `${row.predictionHour}시간 후`,
predictedAccumulatedRainfall: predictedAccumulatedRainfall:
@@ -60,7 +75,8 @@ export const downloadFloodPredictionStatusExcel = async (
data: excelRows as unknown as JSON[], data: excelRows as unknown as JSON[],
fileName: "침수예측현황.xlsx", fileName: "침수예측현황.xlsx",
sheetNameArray: ["침수예측현황"], sheetNameArray: ["침수예측현황"],
headerRange: "A1:J1", // 순번~침수위험도 12개 열(관측소·면적비율 포함)의 헤더 행 범위다.
headerRange: "A1:L1",
headerMapping: HEADERS, headerMapping: HEADERS,
}); });
}; };
@@ -2,9 +2,14 @@
import { useLayoutEffect, useMemo, useRef } from "react"; import { useLayoutEffect, useMemo, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components"; import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components";
/** 시군구별 면적가중 확률강우량 한 셀의 API 응답이다. */ import { downloadExcelWorkbook } from "@/shared/util/download-excel-workbook";
/**
* 시군구별 면적가중 확률강우량 한 셀의 API 응답이다.
* 관측소 기준표 응답에서는 최근접 확률관측소 1곳의 값을 면적가중 없이 그대로 담는다.
*/
export type PrbbtRnflCell = { export type PrbbtRnflCell = {
frequencyYear: number; frequencyYear: number;
durationHour: number; durationHour: number;
@@ -18,18 +23,37 @@ export type PrbbtRnflStation = {
areaRt: number; areaRt: number;
}; };
/** 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다. */ /**
* 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다.
* 시군구 기준표(`/prst/prbbt-rnfl?sggCd=`)는 `sggCd`를, 관측소 기준표(`/prst/prbbt-rnfl/obsvtr?obsrvnBrnchCd=`)는
* `sggCd` 대신 `obsrvnBrnchCd`·`obsrvnBrnchNm`을 담으므로 두 응답을 모두 받도록 식별 필드는 선택값으로 둔다.
*/
export type PrbbtRnflResponse = { export type PrbbtRnflResponse = {
sggCd: string; /** 시군구 기준표 응답의 시군구코드다. 관측소 기준표 응답에는 없다. */
sggCd?: string;
/** 관측소 기준표 응답의 관측지점코드다. 시군구 기준표 응답에는 없다. */
obsrvnBrnchCd?: string;
/** 관측소 기준표 응답의 관측소명이다. 시군구 기준표 응답에는 없다. */
obsrvnBrnchNm?: string;
rainfall: PrbbtRnflCell[]; rainfall: PrbbtRnflCell[];
/** 시군구 기준은 면적가중에 쓴 확률관측소 전부, 관측소 기준은 최근접 확률관측소 1곳(비율 100)이다. */
stations: PrbbtRnflStation[]; stations: PrbbtRnflStation[];
areaRtTotal: number; areaRtTotal: number;
}; };
/**
* 기준표를 연 목록 행의 분석 기준이다.
* `sgg`는 기존 시군구 면적가중 행, `obsvtr`는 관측지점코드가 있는 관측소 기준(새 로직) 행이다.
*/
export type PrbbtRnflTargetKind = "sgg" | "obsvtr";
type PrbbtRnflDialogProps = { type PrbbtRnflDialogProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
sggName: string; /** 상단 제목·표 제목·Excel 파일명에 쓰는 대상 이름이다. 시군구 기준은 시군구명, 관측소 기준은 관측소명이다. */
targetName: string;
/** 대상 이름이 시군구인지 관측소인지 구분해 아래 표 제목과 안내 문구를 고른다. */
targetKind: PrbbtRnflTargetKind;
selectedDurationHour: number | null; selectedDurationHour: number | null;
selectedFrequencyYear: number | null; selectedFrequencyYear: number | null;
data: PrbbtRnflResponse | null; data: PrbbtRnflResponse | null;
@@ -43,11 +67,27 @@ const formatRainfall = (value: number) =>
const formatAreaRatio = (value: number) => const formatAreaRatio = (value: number) =>
Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-"; Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-";
/**
* 아래 확률관측소 표의 제목(Excel 두 번째 시트명 겸용)이다.
* 관측소 기준은 면적가중 없이 최근접 확률관측소 1곳을 쓰므로 `적용 확률관측소`로 보인다.
*/
const STATION_TABLE_TITLE: Record<PrbbtRnflTargetKind, string> = {
sgg: "확률관측소 면적비율",
obsvtr: "적용 확률관측소",
};
/** 기준자료가 없을 때 보이는 안내 문구다. 관측소 기준은 시군구 대신 관측소로 안내한다. */
const EMPTY_MESSAGE: Record<PrbbtRnflTargetKind, string> = {
sgg: "해당 시군구의 확률강우량 기준자료가 없습니다.",
obsvtr: "해당 관측소의 확률강우량 기준자료가 없습니다.",
};
/** 재현기간을 행, 강우지속시간을 열로 구성하고 선택한 두 기준이 교차하는 값만 색으로 강조한다. */ /** 재현기간을 행, 강우지속시간을 열로 구성하고 선택한 두 기준이 교차하는 값만 색으로 강조한다. */
export default function PrbbtRnflDialog({ export default function PrbbtRnflDialog({
open, open,
onOpenChange, onOpenChange,
sggName, targetName,
targetKind,
selectedDurationHour, selectedDurationHour,
selectedFrequencyYear, selectedFrequencyYear,
data, data,
@@ -58,6 +98,8 @@ export default function PrbbtRnflDialog({
const tableScrollRef = useRef<HTMLDivElement>(null); const tableScrollRef = useRef<HTMLDivElement>(null);
/** 목록에서 클릭한 지속시간에 대응하는 표 헤더이다. */ /** 목록에서 클릭한 지속시간에 대응하는 표 헤더이다. */
const selectedDurationHeaderRef = useRef<HTMLTableCellElement>(null); const selectedDurationHeaderRef = useRef<HTMLTableCellElement>(null);
/** 행 종류에 따른 아래 표 제목이다. 시군구 기준 행은 기존 제목을 그대로 쓴다. */
const stationTableTitle = STATION_TABLE_TITLE[targetKind];
const { frequencies, durations, rainfallByKey } = useMemo(() => { const { frequencies, durations, rainfallByKey } = useMemo(() => {
const rainfall = data?.rainfall ?? []; const rainfall = data?.rainfall ?? [];
@@ -77,6 +119,46 @@ export default function PrbbtRnflDialog({
}; };
}, [data]); }, [data]);
/**
* 화면에 표시 중인 확률강우량 표와 관측소 면적비율 표를 두 시트로 내려받는다.
* 파일명은 대상 이름(시군구명 또는 관측소명)을, 두 번째 시트명은 아래 표 제목을 따른다.
*/
const handleExcelDownload = async () => {
const stations = data?.stations ?? [];
await downloadExcelWorkbook({
fileName: `${targetName}_확률강우량`,
sheets: [
{
name: "확률강우량",
rows: [
["재현기간(년)", ...durations.map((hour) => `${hour}시간`)],
...frequencies.map((frequencyYear) => [
frequencyYear,
...durations.map(
(durationHour) =>
rainfallByKey.get(`${frequencyYear}:${durationHour}`) ?? null,
),
]),
],
columnWidths: [14, ...durations.map(() => 12)],
},
{
name: stationTableTitle,
rows: [
["확률관측소코드", "관측소명", "면적비율(%)"],
...stations.map((station) => [
station.prbbtObsvtrCd,
station.prbbtObsvtrNm || "-",
station.areaRt,
]),
["합계", "", data?.areaRtTotal ?? null],
],
columnWidths: [18, 18, 14],
},
],
});
};
/** 표가 표시되면 선택 지속시간 열을 가로 스크롤 영역의 가운데로 맞춘다. */ /** 표가 표시되면 선택 지속시간 열을 가로 스크롤 영역의 가운데로 맞춘다. */
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open || loading || error || selectedDurationHour == null) return; if (!open || loading || error || selectedDurationHour == null) return;
@@ -99,8 +181,16 @@ export default function PrbbtRnflDialog({
className="flex max-h-[90vh] w-[94vw] !max-w-[150rem] flex-col gap-4 overflow-hidden" className="flex max-h-[90vh] w-[94vw] !max-w-[150rem] flex-col gap-4 overflow-hidden"
aria-describedby={undefined} aria-describedby={undefined}
> >
<DialogHeader> {/* 우측 상단 닫기(X) 버튼과 겹치지 않도록 pr-14 여백을 둔다. */}
<DialogTitle>{sggName} 확률강우량</DialogTitle> <DialogHeader className="flex-row items-center justify-between pr-14">
<DialogTitle>{targetName} 확률강우량</DialogTitle>
<Button
type="button"
disabled={loading || !!error || !frequencies.length || !durations.length}
onClick={() => void handleExcelDownload()}
>
엑셀다운로드
</Button>
</DialogHeader> </DialogHeader>
{loading ? ( {loading ? (
@@ -113,14 +203,14 @@ export default function PrbbtRnflDialog({
</p> </p>
) : !frequencies.length || !durations.length ? ( ) : !frequencies.length || !durations.length ? (
<p className="py-8 text-center text-xl" role="status"> <p className="py-8 text-center text-xl" role="status">
해당 시군구의 확률강우량 기준자료가 없습니다. {EMPTY_MESSAGE[targetKind]}
</p> </p>
) : ( ) : (
<> <>
<div ref={tableScrollRef} className="min-h-0 overflow-auto rounded-md border border-border"> <div ref={tableScrollRef} className="min-h-0 overflow-auto rounded-md border border-border">
<table className="w-max min-w-full border-collapse text-right text-lg"> <table className="w-max min-w-full border-collapse text-right text-lg">
<caption className="sr-only"> <caption className="sr-only">
{sggName} 재현기간 및 강우지속시간별 확률강우량(mm) {targetName} 재현기간 및 강우지속시간별 확률강우량(mm)
</caption> </caption>
<thead className="sticky top-0 z-10 bg-muted text-foreground"> <thead className="sticky top-0 z-10 bg-muted text-foreground">
<tr> <tr>
@@ -182,7 +272,7 @@ export default function PrbbtRnflDialog({
</div> </div>
<div className="shrink-0 space-y-2"> <div className="shrink-0 space-y-2">
<h3 className="text-xl font-semibold">확률관측소 면적비율</h3> <h3 className="text-xl font-semibold">{stationTableTitle}</h3>
<div className="max-h-[16rem] overflow-auto rounded-md border border-border"> <div className="max-h-[16rem] overflow-auto rounded-md border border-border">
<table className="w-full border-collapse text-lg"> <table className="w-full border-collapse text-lg">
<thead className="bg-muted text-foreground"> <thead className="bg-muted text-foreground">
@@ -31,9 +31,16 @@ type ApiRow = {
ctpvCd: string; ctpvCd: string;
ctpvNm: string; ctpvNm: string;
sggNm: string; sggNm: string;
/** 관측소 기준(새 로직) 분석 행의 관측지점코드다. 기존 시군구 면적가중 행은 null이다. */
obsrvnBrnchCd: string | null;
/** `/prst` 조회 때 시군구-관측소 매핑에서 붙인 관측소명이다. 기존 로직 행이나 매핑에 없는 조합은 null이다. */
obsrvnBrnchNm: string | null;
/** 시군구 면적 중 관측소 영역의 비율(%)이다. 기존 로직 행이나 매핑에 없는 조합은 null이다. */
areaRt: number | null;
rainBeginningDateTime: string; rainBeginningDateTime: string;
analysisDateTime: string; analysisDateTime: string;
observedAccumulatedRainfall: number | null; observedAccumulatedRainfall: number | null;
/** 분석시각 기준 대표 예측시간이다. 관측소 기준 행의 0은 대표값이 분석시각(관측 누적)이라는 뜻이다. */
predictionHour: number | null; predictionHour: number | null;
predcRainHr: number | null; predcRainHr: number | null;
predictedAccumulatedRainfall: number | null; predictedAccumulatedRainfall: number | null;
@@ -46,6 +53,10 @@ type TableRow = ApiRow &
rowNo: number; rowNo: number;
analysisDateTimeDisplay: string; analysisDateTimeDisplay: string;
rainBeginningDateTimeDisplay: string; rainBeginningDateTimeDisplay: string;
/** 관측소 열 표시값이다. 관측소명이 없으면 `-`이다. */
obsrvnBrnchNmDisplay: string;
/** 면적비율(%) 열 표시값이다. 소수 둘째 자리까지, 값이 없으면 `-`이다. */
areaRtDisplay: string;
observedAccumulatedRainfallDisplay: string; observedAccumulatedRainfallDisplay: string;
predictionHourDisplay: string; predictionHourDisplay: string;
predictedAccumulatedRainfallDisplay: string; predictedAccumulatedRainfallDisplay: string;
@@ -76,47 +87,61 @@ const toFloodMapFrequencyCode = (value: number) => {
return String(normalized).padStart(3, "0"); return String(normalized).padStart(3, "0");
}; };
/**
* 침수예측현황 목록 열 정의다.
* 시군구 뒤에 관측소 기준 분석의 관측소·면적비율 열을 두고, 13개 열의 widthPercent 합은 100으로 맞춘다.
*/
const HEADS: UiHeadProps[] = [ const HEADS: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 5 }, { id: "rowNo", title: "순번", widthPercent: 4 },
{ id: "ctpvNm", title: "시도", widthPercent: 7 }, { id: "ctpvNm", title: "시도", widthPercent: 7 },
{ id: "sggNm", title: "시군구", widthPercent: 8 }, { id: "sggNm", title: "시군구", widthPercent: 8 },
{ id: "obsrvnBrnchNmDisplay", title: "관측소", widthPercent: 7 },
{
id: "areaRtDisplay",
title: "면적비율(%)",
widthPercent: 6,
align: "right",
},
{ {
id: "analysisDateTimeDisplay", id: "analysisDateTimeDisplay",
title: "분석시각", title: "분석시각",
widthPercent: 13, widthPercent: 11,
}, },
{ {
id: "rainBeginningDateTimeDisplay", id: "rainBeginningDateTimeDisplay",
title: "강우시작시각", title: "강우시작시각",
widthPercent: 13, widthPercent: 11,
}, },
{ {
id: "observedAccumulatedRainfallDisplay", id: "observedAccumulatedRainfallDisplay",
title: "관측 누적강수량(mm)", title: "관측 누적강수량(mm)",
widthPercent: 11, widthPercent: 9,
align: "right", align: "right",
}, },
{ {
id: "predictionHourDisplay", id: "predictionHourDisplay",
title: "예측시간", title: "예측시간",
widthPercent: 7, widthPercent: 6,
}, },
{ {
id: "predictedAccumulatedRainfallDisplay", id: "predictedAccumulatedRainfallDisplay",
title: "예측 누적강수량(mm)", title: "예측 누적강수량(mm)",
widthPercent: 12, widthPercent: 9,
align: "right", align: "right",
}, },
{ {
id: "exceededFrequencyYearDisplay", id: "exceededFrequencyYearDisplay",
title: "초과 빈도(년)", title: "초과 빈도(년)",
widthPercent: 8, widthPercent: 7,
align: "right", align: "right",
}, },
{ id: "floodRiskGrade", title: "침수위험도", widthPercent: 8 }, { id: "floodRiskGrade", title: "침수위험도", widthPercent: 7 },
{ id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 8 }, { id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 8 },
]; ];
/** 관측소·면적비율 열이 더해진 목록의 최소 표 너비다. 기본 팝업 너비(1280px)에서는 가로 스크롤 없이 보이도록 잡는다. */
const LIST_MIN_TABLE_WIDTH = "118rem";
/** 날짜·시각의 뒷부분이 잘리지 않도록 전체 표시할 목록 열이다. */ /** 날짜·시각의 뒷부분이 잘리지 않도록 전체 표시할 목록 열이다. */
const FULL_TEXT_TIME_HEAD_IDS = [ const FULL_TEXT_TIME_HEAD_IDS = [
"analysisDateTimeDisplay", "analysisDateTimeDisplay",
@@ -157,6 +182,34 @@ const predictionHourText = (value: number | null) => {
return hour > 0 ? `${hour}시간 후` : "-"; return hour > 0 ? `${hour}시간 후` : "-";
}; };
/** 관측지점코드가 있으면 관측소 기준(새 로직) 분석 행이다. 기준표 API·표시 문구를 고를 때 쓴다. */
const isObsvtrRow = (row: Pick<ApiRow, "obsrvnBrnchCd">) =>
Boolean(row.obsrvnBrnchCd);
/**
* 목록 예측시간 열의 표시문구를 정한다.
* 관측소 기준 행의 예측시간 0은 대표값이 분석시각(관측 누적)이라는 뜻이므로 `0시간 후`로 보인다.
* 그 밖의 행은 기존 `predictionHourText` 규칙(0 이하 `-`)을 그대로 따른다.
*/
const predictionHourDisplayText = (
row: Pick<ApiRow, "predictionHour" | "obsrvnBrnchCd">,
) =>
isObsvtrRow(row) && row.predictionHour === 0
? "0시간 후"
: predictionHourText(row.predictionHour);
/**
* 확률강우량 기준표 버튼 aria-label과 모달 제목에 쓸 대상 이름이다.
* 관측소 기준 행은 관측소명(목록 → 기준표 응답 → 관측지점코드 순으로 대체), 기존 행은 시군구명을 쓴다.
*/
const probabilityTargetName = (
row: Pick<ApiRow, "obsrvnBrnchCd" | "obsrvnBrnchNm" | "sggNm">,
responseStationName?: string,
) =>
isObsvtrRow(row)
? row.obsrvnBrnchNm || responseStationName || row.obsrvnBrnchCd || ""
: row.sggNm;
/** 분석 결과의 대표 누적강우시간을 확률강우량 기준표 링크 문구로 표시한다. */ /** 분석 결과의 대표 누적강우시간을 확률강우량 기준표 링크 문구로 표시한다. */
const probabilityDurationText = (value: number | null) => { const probabilityDurationText = (value: number | null) => {
const hour = Number(value); const hour = Number(value);
@@ -291,11 +344,14 @@ export default function FldngPredcPrstPopupContent() {
rainBeginningDateTimeDisplay: displayDate( rainBeginningDateTimeDisplay: displayDate(
item.rainBeginningDateTime, item.rainBeginningDateTime,
), ),
// 기존 로직 행(관측지점코드 null)이나 매핑에 없는 조합은 관측소·면적비율을 `-`로 보인다.
obsrvnBrnchNmDisplay: item.obsrvnBrnchNm || "-",
areaRtDisplay: formatNumber(item.areaRt, 2),
observedAccumulatedRainfallDisplay: formatNumber( observedAccumulatedRainfallDisplay: formatNumber(
item.observedAccumulatedRainfall, item.observedAccumulatedRainfall,
1, 1,
), ),
predictionHourDisplay: predictionHourText(item.predictionHour), predictionHourDisplay: predictionHourDisplayText(item),
predictedAccumulatedRainfallDisplay: formatNumber( predictedAccumulatedRainfallDisplay: formatNumber(
item.predictedAccumulatedRainfall, item.predictedAccumulatedRainfall,
1, 1,
@@ -417,9 +473,19 @@ export default function FldngPredcPrstPopupContent() {
} }
}; };
/** 선택 시군구의 확률강우량과 관측소 비율을 조회한다. 지도 행 클릭은 버튼에서 차단한다. */ /**
* 선택 행의 확률강우량과 관측소 비율을 조회한다. 지도 행 클릭은 버튼에서 차단한다.
* 관측지점코드가 있는 관측소 기준 행은 판정에 쓴 최근접 확률관측소 기준표(`/prst/prbbt-rnfl/obsvtr`)를,
* 기존 시군구 기준 행은 지금처럼 시군구 면적가중 기준표(`/prst/prbbt-rnfl`)를 부른다.
*/
const handleProbabilityClick = async (row: TableRow) => { const handleProbabilityClick = async (row: TableRow) => {
if (!row.sggCd || !Number.isInteger(Number(row.predcRainHr)) || Number(row.predcRainHr) <= 0) { // 관측소 기준 행이면 조회에 쓸 관측지점코드, 기존 행이면 null이다.
const obsrvnBrnchCd = row.obsrvnBrnchCd || null;
if (
(!obsrvnBrnchCd && !row.sggCd) ||
!Number.isInteger(Number(row.predcRainHr)) ||
Number(row.predcRainHr) <= 0
) {
return; return;
} }
const requestId = ++probabilityRequestIdRef.current; const requestId = ++probabilityRequestIdRef.current;
@@ -428,16 +494,22 @@ export default function FldngPredcPrstPopupContent() {
setProbabilityError(null); setProbabilityError(null);
setProbabilityLoading(true); setProbabilityLoading(true);
try { try {
const params = new URLSearchParams({ sggCd: row.sggCd }); // 행 종류에 맞는 기준표 API 주소다. 기존 시군구 기준 행은 이전과 같은 sggCd 쿼리를 쓴다.
const response = await getRawJson( const requestUrl = obsrvnBrnchCd
`/sewerage-service/api/v1/fldng/predc/prst/prbbt-rnfl?${params.toString()}`, ? `/sewerage-service/api/v1/fldng/predc/prst/prbbt-rnfl/obsvtr?${new URLSearchParams({ obsrvnBrnchCd }).toString()}`
); : `/sewerage-service/api/v1/fldng/predc/prst/prbbt-rnfl?${new URLSearchParams({ sggCd: row.sggCd }).toString()}`;
const response = await getRawJson(requestUrl);
if (requestId !== probabilityRequestIdRef.current) return; if (requestId !== probabilityRequestIdRef.current) return;
const payload: PrbbtRnflResponse = response?.data ?? response; const payload: PrbbtRnflResponse = response?.data ?? response;
setProbabilityData(payload); setProbabilityData(payload);
} catch (error) { } catch (error) {
if (requestId !== probabilityRequestIdRef.current) return; if (requestId !== probabilityRequestIdRef.current) return;
console.error("시군구 확률강우량 조회 중 오류:", error); console.error(
obsrvnBrnchCd
? "관측소 확률강우량 조회 중 오류:"
: "시군구 확률강우량 조회 중 오류:",
error,
);
setProbabilityError("확률강우량 기준표를 조회하지 못했습니다."); setProbabilityError("확률강우량 기준표를 조회하지 못했습니다.");
} finally { } finally {
if (requestId === probabilityRequestIdRef.current) { if (requestId === probabilityRequestIdRef.current) {
@@ -479,6 +551,7 @@ export default function FldngPredcPrstPopupContent() {
getItems: (response) => response.content, getItems: (response) => response.content,
getTotal: (response) => response.totalElements, getTotal: (response) => response.totalElements,
}); });
// API 원본 행을 그대로 넘겨 관측지점코드·관측소명·면적비율도 Excel 헬퍼에서 함께 쓴다.
await downloadFloodPredictionStatusExcel(result.items); await downloadFloodPredictionStatusExcel(result.items);
} catch (error) { } catch (error) {
console.error("침수예측현황 Excel 다운로드 중 오류:", error); console.error("침수예측현황 Excel 다운로드 중 오류:", error);
@@ -659,7 +732,7 @@ export default function FldngPredcPrstPopupContent() {
<button <button
type="button" type="button"
className="font-medium text-primary underline underline-offset-2 hover:opacity-75 focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" className="font-medium text-primary underline underline-offset-2 hover:opacity-75 focus-visible:rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
aria-label={`${row.sggNm} ${row.probabilityRainfallDisplay} 확률강우량 기준표 보기`} aria-label={`${probabilityTargetName(row)} ${row.probabilityRainfallDisplay} 확률강우량 기준표 보기`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
void handleProbabilityClick(row); void handleProbabilityClick(row);
@@ -671,7 +744,7 @@ export default function FldngPredcPrstPopupContent() {
) : undefined ) : undefined
} }
scrollMode="horizontal" scrollMode="horizontal"
minTableWidth="112rem" minTableWidth={LIST_MIN_TABLE_WIDTH}
fullTextHeadIds={FULL_TEXT_TIME_HEAD_IDS} fullTextHeadIds={FULL_TEXT_TIME_HEAD_IDS}
fillAvailableHeight={rows.length === 0} fillAvailableHeight={rows.length === 0}
emptyMessage="조회 조건에 해당하는 침수예측현황이 없습니다." emptyMessage="조회 조건에 해당하는 침수예측현황이 없습니다."
@@ -706,7 +779,17 @@ export default function FldngPredcPrstPopupContent() {
<PrbbtRnflDialog <PrbbtRnflDialog
open={probabilityRow !== null} open={probabilityRow !== null}
onOpenChange={handleProbabilityOpenChange} onOpenChange={handleProbabilityOpenChange}
sggName={probabilityRow?.sggNm ?? ""} targetName={
probabilityRow
? probabilityTargetName(
probabilityRow,
probabilityData?.obsrvnBrnchNm,
)
: ""
}
targetKind={
probabilityRow && isObsvtrRow(probabilityRow) ? "obsvtr" : "sgg"
}
selectedDurationHour={probabilityRow?.predcRainHr ?? null} selectedDurationHour={probabilityRow?.predcRainHr ?? null}
selectedFrequencyYear={probabilityRow?.exceededFrequencyYear ?? null} selectedFrequencyYear={probabilityRow?.exceededFrequencyYear ?? null}
data={probabilityData} data={probabilityData}