diff --git a/.gitignore b/.gitignore index 6eedddd04..7e82f80c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # turbo .turbo/ -.vscode/**/* +**/.vscode/ +*.code-workspace # dependencies node_modules/ diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index ea1cd8dc1..000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - // IntelliSense를 사용하여 가능한 특성에 대해 알아보세요. - // 기존 특성에 대한 설명을 보려면 가리킵니다. - // 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요. - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "dev:as 디버그", - "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "run", - "dev:as" - ], - "skipFiles": [ - "/**" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "sourceMaps": true, - "env": { - "NODE_OPTIONS": "--inspect" - } - }, - { - "type": "node", - "request": "launch", - "name": "dev:cs 디버그", - "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "run", - "dev:cs" - ], - "skipFiles": [ - "/**" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "sourceMaps": true, - "env": { - "NODE_OPTIONS": "--inspect" - } - }, - { - "type": "node", - "request": "attach", - "name": "dev:cs 서버 연결 (attach)", - "port": 9229, - "restart": true, - "skipFiles": [ - "/**" - ], - "sourceMaps": true, - "resolveSourceMapLocations": [ - "${workspaceFolder}/apps/cs/**", - "!**/node_modules/**" - ] - }, - { - "type": "chrome", - "request": "launch", - "name": "dev:cs 클라이언트 디버그 (Chrome)", - "url": "http://localhost:3000", - "webRoot": "${workspaceFolder}/apps/cs", - "sourceMaps": true - }, - { - "type": "node", - "request": "launch", - "name": "dev:st 디버그", - "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "run", - "dev:st" - ], - "skipFiles": [ - "/**" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "sourceMaps": true, - "env": { - "NODE_OPTIONS": "--inspect" - } - } - ] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index f60bf453a..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "chat.tools.terminal.autoApprove": { - "pnpm": true - } -} \ No newline at end of file diff --git a/apps/sn/.env.local b/apps/sn/.env.local index dce96bf40..256b4daac 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -11,7 +11,7 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver #NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y} NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} -SERVER_API_URL = http://dev.onasys.co.kr:8000 +SERVER_API_URL = https://apigateway.onasys.co.kr SEWERAGE_API_URL = http://localhost:8883 # SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 DC_URL = https://apigateway.apps.rsonesoft.com diff --git a/apps/sn/.env.mindone b/apps/sn/.env.mindone index 84202b57b..fb477b3bb 100644 --- a/apps/sn/.env.mindone +++ b/apps/sn/.env.mindone @@ -6,7 +6,7 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token NEXT_PUBLIC_REFRESH_TOKEN = refresh-token NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302 NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000 -SERVER_API_URL = http://dev.onasys.co.kr:8000 +SERVER_API_URL = https://apigateway.onasys.co.kr NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 76b511b8f..ddfeda2ff 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -166,6 +166,7 @@ $commonDocsRoot = Join-Path $beRepoRoot 'docs' ## 작업 원칙 +- `sn` 프론트엔드 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `src/shared/util/download-excel-workbook.ts` 재사용을 먼저 검토한다. 세부 기준은 [컴포넌트정책의 공통 유틸 재사용 기준](docs/기준문서/004-컴포넌트정책.md#공통-유틸-재사용-기준)을 따른다. 이 코드 경로는 `FE_APP_ROOT` 기준이다. - 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다. 1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다. diff --git a/apps/sn/app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-utils.ts b/apps/sn/app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-utils.ts index 430e81a73..96f8d3fed 100644 --- a/apps/sn/app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-utils.ts +++ b/apps/sn/app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-utils.ts @@ -66,7 +66,8 @@ export const normalizeAgedPipeStatusByCtpvFilters = ( filters: Partial, ): AgedPipeStatusByCtpvFilters => { const currentYear = new Date().getFullYear(); - let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2)); + // 최초 진입·초기화는 당해연도 1년 조회로 시작하며 직접 선택한 범위는 유지한다. + let startYear = Math.trunc(Number(filters.startYear ?? currentYear)); let endYear = Math.trunc(Number(filters.endYear ?? currentYear)); let elapsedYears = Math.trunc( Number( @@ -74,7 +75,7 @@ export const normalizeAgedPipeStatusByCtpvFilters = ( ), ); - if (!Number.isFinite(startYear)) startYear = currentYear - 2; + if (!Number.isFinite(startYear)) startYear = currentYear; if (!Number.isFinite(endYear)) endYear = currentYear; if ( !Number.isFinite(elapsedYears) || diff --git a/apps/sn/app/(map-layout)/map/components/analysis-menu/analysis-popup-menu.tsx b/apps/sn/app/(map-layout)/map/components/analysis-menu/analysis-popup-menu.tsx index 182ae535a..6b295d10c 100644 --- a/apps/sn/app/(map-layout)/map/components/analysis-menu/analysis-popup-menu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysis-menu/analysis-popup-menu.tsx @@ -10,9 +10,7 @@ import { ChartColumnBig, ChevronUp, PanelsTopLeft, - PencilRuler, Search, - Settings2, TriangleAlert, Waves, } from "lucide-react"; @@ -28,8 +26,6 @@ import { getRawJson } from "@/shared/api/sewerage-api"; type Props = { onOpenPopup: (popupKey: WindowPopupKey) => Window | null; onOpenInfoSearch: () => void; - onToggleGisEdit?: () => void; - isGisEditActive?: boolean; isInfoSearchActive?: boolean; initialActiveKey?: WindowPopupKey | null; }; @@ -89,16 +85,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{ label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title, icon: ChartColumnBig, }, - { - key: "gisEditPopup", - label: windowPopupDefinitions.gisEditPopup.title, - icon: PencilRuler, - }, - { - key: "gisManagementPopup", - label: windowPopupDefinitions.gisManagementPopup.title, - icon: Settings2, - }, { key: "flowRiskPopup", label: windowPopupDefinitions.flowRiskPopup.title, @@ -119,8 +105,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch, - onToggleGisEdit, - isGisEditActive = false, isInfoSearchActive = false, initialActiveKey = null, }: Props) { @@ -155,12 +139,6 @@ export default function AnalysisPopupMenu({ }; }, []); - useEffect(() => { - if (!isGisEditActive && activeKey === "gisEditPopup") { - setActiveKey(null); - } - }, [activeKey, isGisEditActive]); - useEffect(() => { if (!isInfoSearchActive && activeKey === "infoSearch") { setActiveKey(null); @@ -214,10 +192,7 @@ export default function AnalysisPopupMenu({
{ANALYSIS_POPUP_ITEMS.map((item) => { const Icon = item.icon; - const isActive = - item.key === "gisEditPopup" - ? isGisEditActive - : activeKey === item.key; + const isActive = activeKey === item.key; const label = item.key === "fldngPredcPrst" ? `침수예측현황(${seriousPredictionCount}건)` @@ -233,12 +208,6 @@ export default function AnalysisPopupMenu({ : "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary" }`} onClick={() => { - if (item.key === "gisEditPopup" && onToggleGisEdit) { - setActiveKey(item.key); - onToggleGisEdit(); - return; - } - setActiveKey(item.key); if (item.key === "infoSearch") { diff --git a/apps/sn/app/(map-layout)/map/components/exmn/exmn-map-types.ts b/apps/sn/app/(map-layout)/map/components/exmn/exmn-map-types.ts index fdfca5390..2425d856a 100644 --- a/apps/sn/app/(map-layout)/map/components/exmn/exmn-map-types.ts +++ b/apps/sn/app/(map-layout)/map/components/exmn/exmn-map-types.ts @@ -127,7 +127,7 @@ const normalizeControlNode = ( ...node, lyrSn: nodeId, id: nodeId, - // DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다. + // DB에 저장된 과거 명칭과 무관하게 조사사업 표준 화면 명칭으로 표시한다. lyrNm: fallbackName, upperLyrSn: upperId, upId: upperId, diff --git a/apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts b/apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts index 9e2e5f5b6..9b4f32f5c 100644 --- a/apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts +++ b/apps/sn/app/(map-layout)/map/components/exmn/use-exmn-result-map.ts @@ -87,7 +87,7 @@ export const useExmnResultMap = (options: Options) => { [gisApp, mapRef], ); - /** 지역 검색값 없이 전체를 요청하고, 선택 진입 context는 위치 이동 범위 조회에만 사용한다. */ + /** 일반 진입은 전체, 조사관리 지도보기는 선택 사업·처리분구만 조회해 겹친 타 사업 선택을 방지한다. */ const loadLayer = useCallback( async (job: ExmnMapJobType, filters: ExmnMapFilters) => { const map = mapRef.current; @@ -109,7 +109,9 @@ export const useExmnResultMap = (options: Options) => { const response = await getExmnMapLayer( job, filters, - {}, + entry.exmnBizId && entry.trtdstId + ? { exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId } + : {}, controller.signal, ); if (!active()) return; diff --git a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx index cc4fce0ca..3b503b3b5 100644 --- a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx @@ -5,7 +5,7 @@ "use client"; import { SelectBox } from "@common/business_components/ui"; -import { FLDNG_HSTRY_MIN_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types"; +import { FLDNG_HSTRY_MIN_YEAR, FLDNG_HSTRY_MAX_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types"; import { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils"; import LayerSettingCard from "../layer-setting/layer-setting-card"; @@ -26,10 +26,10 @@ export default function FldngHstryLayerSettingPanel({ onClose, }: Props) { const normalizedRange = normalizeFldngHstryYearRange(yearRange); - const currentYear = new Date().getFullYear(); + // 제공 자료의 최대 연도부터 최소 연도까지 선택 목록을 구성한다. const yearOptions = Array.from( - { length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 }, - (_, index) => String(currentYear - index), + { length: FLDNG_HSTRY_MAX_YEAR - FLDNG_HSTRY_MIN_YEAR + 1 }, + (_, index) => String(FLDNG_HSTRY_MAX_YEAR - index), ); const startYearOptions = yearOptions .filter((year) => Number(year) <= Number(normalizedRange.endYear)) diff --git a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx index dd25bbb67..f2fa3f1d3 100644 --- a/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx @@ -14,6 +14,7 @@ import { useAlertStore } from "@/shared/lib"; import useStore from "@/entities/reference-data/model/reference-data-store"; import LoadingOverlay from "@/components/ui/loading-bar"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types"; import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils"; @@ -421,9 +422,13 @@ export default function FldngHstryPopupPage() { } /> -
- 행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다. -
+ {/* 기존 행 클릭 안내에 인수테스트 요구(자료 원천 표시) 출처 문구를 글머리표로 더한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx index 5474e8fad..8eadb6495 100644 --- a/apps/sn/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx @@ -11,6 +11,7 @@ import { getData } from "@/shared/api/sewerage-api"; import LoadingOverlay from "@/components/ui/loading-bar"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import useStore from "@/entities/reference-data/model/reference-data-store"; import { @@ -226,7 +227,12 @@ export default function FloodDngrMapPopupPage() { const response = await getFloodDngrMapJson( `/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`, ); - const payload = response as FloodDngrMapExtent; + const payload = response as FloodDngrMapExtent | null | undefined; + // 성공 응답의 null·생략된 data는 위치 부재이다. 좌표 접근과 지도 메시지를 중단한다. + if (payload == null) { + setErrorMessage("위치 정보가 없습니다"); + return; + } const extent = [ payload?.minX, payload?.minY, @@ -237,7 +243,7 @@ export default function FloodDngrMapPopupPage() { extent.length !== 4 || extent.some((value) => !Number.isFinite(value)) ) { - throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다."); + throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다."); } window.opener?.postMessage( { @@ -253,11 +259,9 @@ export default function FloodDngrMapPopupPage() { } catch (error) { console.error("도시침수지도 위치 이동 중 오류:", error); const message = - error instanceof Error && error.message.includes("404") - ? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다." - : error instanceof Error - ? error.message - : "도시침수지도 위치 이동 중 오류가 발생했습니다."; + error instanceof Error + ? error.message + : "도시침수지도 위치 이동 중 오류가 발생했습니다."; setErrorMessage(message); } finally { setIsLoading(false); @@ -395,6 +399,11 @@ export default function FloodDngrMapPopupPage() { onRowClick={(row) => void handleRowClick(row)} rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`} /> + + {/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/gis-edit/use-gis-edit.ts b/apps/sn/app/(map-layout)/map/components/gis-edit/use-gis-edit.ts index f37e91f78..403ddbaf4 100644 --- a/apps/sn/app/(map-layout)/map/components/gis-edit/use-gis-edit.ts +++ b/apps/sn/app/(map-layout)/map/components/gis-edit/use-gis-edit.ts @@ -34,7 +34,7 @@ import type { export const useGisEdit = (treeData: any[]) => { const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]); - // GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다. + // GIS 편집 모드 on/off는 지도 우측 GIS 편집 아이콘과 직접 연결된다. const [isEditMode, setIsEditMode] = useState(false); // 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다. const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl"); diff --git a/apps/sn/app/(map-layout)/map/components/gis-tools/gis-tool-buttons.tsx b/apps/sn/app/(map-layout)/map/components/gis-tools/gis-tool-buttons.tsx new file mode 100644 index 000000000..846de173a --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gis-tools/gis-tool-buttons.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { PencilRuler, Settings2 } from "lucide-react"; + +type Props = { + isGisEditActive: boolean; + onToggleGisEdit: () => void; + onOpenGisManagement: () => Window | null; +}; + +/** 기존 지도 도구와 같은 크기로 GIS 버튼의 기본·활성·키보드 포커스를 표시한다. */ +const getButtonClass = (isActive: boolean) => + `flex h-[40px] w-[40px] items-center justify-center p-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${ + isActive + ? "bg-primary text-primary-foreground" + : "bg-background text-label hover:bg-primary hover:text-primary-foreground" + }`; + +/** 분석도구 메뉴와 독립적으로 GIS 편집 모드와 관리 팝업의 진입 버튼을 제공한다. */ +export default function GisToolButtons({ + isGisEditActive, + onToggleGisEdit, + onOpenGisManagement, +}: Props) { + // 관리 팝업이 닫히면 버튼의 활성 표시도 해제한다. + const [isGisManagementActive, setIsGisManagementActive] = useState(false); + // 렌더링과 무관한 감지 타이머를 보관해 재클릭·화면 이탈 시 중복 실행을 방지한다. + const closeWatcherRef = useRef(null); + + useEffect(() => () => { + if (closeWatcherRef.current !== null) { + window.clearInterval(closeWatcherRef.current); + } + }, []); + + /** 기존 팝업 실행 함수를 호출하고 차단·닫힘 상태를 버튼에 반영한다. */ + const handleOpenGisManagement = () => { + if (closeWatcherRef.current !== null) { + window.clearInterval(closeWatcherRef.current); + closeWatcherRef.current = null; + } + const popup = onOpenGisManagement(); + setIsGisManagementActive(Boolean(popup && !popup.closed)); + if (!popup || popup.closed) return; + + closeWatcherRef.current = window.setInterval(() => { + if (!popup.closed) return; + if (closeWatcherRef.current !== null) { + window.clearInterval(closeWatcherRef.current); + closeWatcherRef.current = null; + } + setIsGisManagementActive(false); + }, 300); + }; + + return ( +
+ + +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx index c6b50038d..9570ce698 100644 --- a/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx @@ -17,6 +17,7 @@ import { useUserStore } from "@/shared/lib/use-user-store"; import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, @@ -522,6 +523,13 @@ export default function PipeAnalysisPopupPage() { } /> + + {/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx index 0d1b3d15f..6dd5edb9a 100644 --- a/apps/sn/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx @@ -10,6 +10,7 @@ import { StandardPopupLayout } from "@common/business_components"; import { getRawJson } from "@/shared/api/sewerage-api"; import LoadingOverlay from "@/components/ui/loading-bar"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { useAlertStore } from "@/shared/lib"; @@ -309,6 +310,11 @@ export default function SbmrgDngrMapPopupPage() { ].join(":") } /> + + {/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */} + ); } diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts new file mode 100644 index 000000000..44155f5b1 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts @@ -0,0 +1,126 @@ +/** 지반침하 상세의 표시값과 다운로드 시점 관로 조회 결과를 두 시트로 저장한다. */ +import { getRawJson } from "@/shared/api/sewerage-api"; +import { + downloadExcelWorkbook, + type ExcelCellValue, + type ExcelSheetSpec, +} from "@/shared/util/download-excel-workbook"; +import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types"; + +type DownloadOptions = { + detail: SinkholeDetail; + radiusMeters: number; + summaryEntries: Array<[string, string]>; + getPipeUseName: (pipe: SinkholeNearbyPipe) => string; +}; + +/** 식별자는 문자열을 유지하고 결측 숫자는 0으로 바꾸지 않는다. */ +const numberCell = (value: unknown): ExcelCellValue => { + if (value == null || String(value).trim() === "") return "-"; + const number = Number(value); + return Number.isFinite(number) ? number : String(value); +}; + +/** 화면과 같은 순서의 발생정보 및 반경 내 관로 목록 시트 명세를 만든다. */ +export const buildSinkholeDetailSheets = ( + options: DownloadOptions, + pipes: SinkholeNearbyPipe[], + noCoordinates: boolean, +): ExcelSheetSpec[] => { + const pipeRows: ExcelCellValue[][] = pipes.map((pipe, index) => [ + index + 1, + String(pipe.pipeId ?? "").trim() || "-", + options.getPipeUseName(pipe), + numberCell(pipe.elapsedYears), + numberCell(pipe.distanceMeters), + ]); + if (pipeRows.length === 0) + pipeRows.push([ + noCoordinates + ? "좌표가 없어 반경 내 하수관로를 조회할 수 없습니다." + : "반경 내 하수관로 정보가 없습니다.", + ]); + return [ + { + name: "지반침하정보", + rows: [["항목", "내용"], ...options.summaryEntries], + columnWidths: [22, 75], + }, + { + name: "하수관로목록", + rows: [ + ["사고번호", String(options.detail.acdntNo)], + ["조회 반경(m)", options.radiusMeters], + ["조회 기준", "기존 반경 조회 기준, 최대 200건 (가까운 거리순)"], + [], + ["NO", "관로 ID", "용도", "경과년도(년)", "거리(m)"], + ...pipeRows, + ], + columnWidths: [12, 36, 24, 20, 18], + autoFilterRange: `A5:E${5 + pipes.length}`, + mergeRanges: ["B3:E3", ...(pipes.length === 0 ? ["A6:E6"] : [])], + }, + ]; +}; + +/** 기존 반경 API를 다시 조회하여 실패를 빈 목록으로 내보내지 않는다. */ +export const downloadSinkholeDetailExcel = async (options: DownloadOptions) => { + // 비동기 재조회 동안 호출자의 상세·반경·표시 행이 변경되어도 클릭 시점을 유지한다. + const snapshot: DownloadOptions = { + ...options, + detail: { ...options.detail }, + summaryEntries: options.summaryEntries.map(([title, value]) => [ + title, + value, + ]), + }; + const { detail, radiusMeters } = snapshot; + const noCoordinates = String(detail.coordStatus ?? "").trim() !== "SUCCESS"; + let pipes: SinkholeNearbyPipe[] = []; + if (!noCoordinates) { + const hasPoint = + detail.x != null && + detail.y != null && + String(detail.x).trim() !== "" && + String(detail.y).trim() !== "" && + Number.isFinite(Number(detail.x)) && + Number.isFinite(Number(detail.y)); + const params = new URLSearchParams({ radiusMeters: String(radiusMeters) }); + if (hasPoint) { + params.set("x", String(detail.x)); + params.set("y", String(detail.y)); + } + const path = hasPoint + ? "nearbyPipesByPoint" + : `${encodeURIComponent(detail.acdntNo)}/nearbyPipes`; + const raw = await getRawJson( + `/sewerage-service/api/v1/sinkholes/${path}?${params}`, + ); + const response = raw?.data ?? raw; + if (response?.coordStatus !== "SUCCESS") + throw new Error( + "관로 조회 좌표를 확인할 수 없습니다. 상세정보를 다시 조회해 주세요.", + ); + if ( + !Array.isArray(response?.items) || + response.items.some( + (item: unknown) => + !item || + typeof item !== "object" || + Array.isArray(item) || + !("pipeId" in item) || + !("fid" in item), + ) + ) + throw new Error( + "관로 조회 응답이 올바르지 않습니다. 다시 시도해 주세요.", + ); + pipes = response.items; + } + const now = new Date(); + const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + await downloadExcelWorkbook({ + fileName: `지반침하정보_${detail.acdntNo}_${date}.xlsx`, + sheets: buildSinkholeDetailSheets(snapshot, pipes, noCoordinates), + }); +}; diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx index 64681befb..816bfa053 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx @@ -4,12 +4,13 @@ */ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@common/components"; import { VerticalTable } from "@/components/ui/vertical-table"; import { getCmsData } from "@/shared/api/external-service-api"; import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types"; import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes"; +import { downloadSinkholeDetailExcel } from "./sinkhole-detail-excel"; type Props = { detail: SinkholeDetail; @@ -81,6 +82,9 @@ export default function SinkholeDetailPanel({ }: Props) { // 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다. const { restoreStatusLabels } = useSinkholeCommonCodes(); + // 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다. + const [isDownloading, setIsDownloading] = useState(false); + const downloadInProgressRef = useRef(false); const [pipeUseCodeMap, setPipeUseCodeMap] = useState>( new Map(), ); @@ -127,27 +131,24 @@ export default function SinkholeDetailPanel({ return { average, oldest }; }, [nearbyPipes]); - const summaryTableData = useMemo( - () => { - const restoreStatus = String(detail.restoreStatus ?? "").trim(); - return [ - { - acdntNo: display(detail.acdntNo), - ocrnYmd: display(detail.ocrnYmd), - fullAddress: display(detail.fullAddress || detail.daddr), - sizeWidth: display(detail.sizeWidth), - sizeLength: display(detail.sizeLength), - sizeDepth: display(detail.sizeDepth), - detailCause: display(detail.detailCause), - restoreStatus: display( - restoreStatusLabels[restoreStatus] || restoreStatus, - ), - restoreCompletedYmd: display(detail.restoreCompletedYmd), - }, - ]; - }, - [detail, restoreStatusLabels], - ); + const summaryTableData = useMemo(() => { + const restoreStatus = String(detail.restoreStatus ?? "").trim(); + return [ + { + acdntNo: display(detail.acdntNo), + ocrnYmd: display(detail.ocrnYmd), + fullAddress: display(detail.fullAddress || detail.daddr), + sizeWidth: display(detail.sizeWidth), + sizeLength: display(detail.sizeLength), + sizeDepth: display(detail.sizeDepth), + detailCause: display(detail.detailCause), + restoreStatus: display( + restoreStatusLabels[restoreStatus] || restoreStatus, + ), + restoreCompletedYmd: display(detail.restoreCompletedYmd), + }, + ]; + }, [detail, restoreStatusLabels]); const summaryHeadList = [ { id: "acdntNo", title: "사고번호" }, @@ -161,6 +162,35 @@ export default function SinkholeDetailPanel({ { id: "restoreCompletedYmd", title: "복구완료일자" }, ]; + /** 클릭 시의 상세·반경·표시값을 고정해 탭 전환 중에도 한 사고만 내보낸다. */ + const handleDownload = async () => { + if (downloadInProgressRef.current) return; + downloadInProgressRef.current = true; + setIsDownloading(true); + const summary = summaryTableData[0] as Record; + try { + await downloadSinkholeDetailExcel({ + detail: { ...detail }, + radiusMeters, + summaryEntries: summaryHeadList.map(({ id, title }) => [ + title, + summary[id] ?? "-", + ]), + getPipeUseName, + }); + } catch (error) { + console.error("지반침하 상세 엑셀 다운로드 실패:", error); + alert( + error instanceof Error + ? error.message + : "엑셀 다운로드 중 오류가 발생했습니다.", + ); + } finally { + downloadInProgressRef.current = false; + setIsDownloading(false); + } + }; + return (