diff --git a/apps/fo/.env.local b/apps/fo/.env.local index d5e6751ed..646d80c2c 100644 --- a/apps/fo/.env.local +++ b/apps/fo/.env.local @@ -17,7 +17,7 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service 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} # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / #NEXT_PUBLIC_SERVER_API_URL = http://localhost:8883/operation-service diff --git a/apps/fo/.env.mindone b/apps/fo/.env.mindone index 5b5ad726e..2fd7e91c2 100644 --- a/apps/fo/.env.mindone +++ b/apps/fo/.env.mindone @@ -16,4 +16,4 @@ NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service 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} -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login \ No newline at end of file +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = / \ No newline at end of file diff --git a/apps/fo/.env.production b/apps/fo/.env.production index d6940b0e9..bd0d0f04b 100644 --- a/apps/fo/.env.production +++ b/apps/fo/.env.production @@ -17,3 +17,4 @@ NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/dc-s NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x} +NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login \ No newline at end of file diff --git a/apps/sn/.env.local b/apps/sn/.env.local index 2bf41d1d9..33b8ef842 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -12,6 +12,7 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver 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} NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000 +NEXT_PUBLIC_SEWERAGE_API_URL = http://localhost:8883 NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index ba16d7f48..291eb61d6 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -10,15 +10,15 @@ ## 공통 시작 기준 -Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기준은 `D:\workspace\AGENTS.md`를 따른다. +Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 기준은 `AGENTS.md`를 따른다. 이 파일은 프론트엔드 `sn` 앱 세부 기준이다. 백엔드(`sewerage-service`)와 함께 수정하거나 API/응답/DB/인증/권한 연동 영향이 있는 작업은 아래 파일을 함께 확인한다. -- 공통 시작 기준: `D:\workspace\AGENTS.md` -- 백엔드 세부 기준: `D:\workspace\IIPBS\sewerage-service\AGENTS.md` -- 프론트엔드 `sn` 앱 기준: `D:\workspace\IIPBS-FRONTEND\frontend\apps\sn\AGENTS.md` +- 공통 시작 기준: `AGENTS.md` +- 백엔드 세부 기준: `BE_REPO_ROOT/AGENTS.md` +- 프론트엔드 `sn` 앱 기준: `FE_APP_ROOT/AGENTS.md` --- @@ -108,11 +108,14 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 5. 불확실한 경우 먼저 계획을 제시한다. 6. 변경 전후 차이, 영향 범위, 검증 방법을 명확히 설명한다. 7. 기존 주석은 임의로 제거하지 않는다. -8. 복잡한 컴포넌트, 훅, 분기 로직, 상태 흐름, 데이터 변환 로직은 함수/메서드 단위 설명 주석을 충분히 작성한다. +8. 신규/수정 함수, 주요 상수, 주요 변수, `useState`, `useRef`, `useMemo`, `useCallback`에는 화면/API/상태 흐름에서 맡는 역할을 알 수 있는 주석을 작성한다. 9. 한글이 깨지지 않도록 반드시 UTF-8로 유지한다. 10. 작업 범위를 넓히기보다 `sn` 내부 해결을 우선한다. 11. 백엔드 API 응답과 실제 브라우저 동작까지 함께 확인하는 것을 기본으로 한다. 12. 작업 문서는 `apps/sn/docs/` 하위에 작업 단위 폴더를 만들어 기록한다. +13. 신규 화면이나 UI 변경은 먼저 기존 `sn` 화면과 공통 컴포넌트/레이아웃 패턴을 따르는 방향으로 설계한다. +14. `BaseLayout`, `NoFooterLayout`, `PageLayout`, `SearchDiv`, `SearchFormLeft/Right`, `ListDiv`, `UiTable`, `UiModal`, `Gis` 조합을 우선 재사용한다. +15. 새 UI 구조가 꼭 필요하면 기존 패턴으로 해결되지 않는 이유를 먼저 설명한다. --- @@ -166,7 +169,8 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 - 경로: `apps/sn/app/html` - 이 디렉터리는 퍼블리싱/정적 HTML/로직 검토 목적의 별도 영역으로 본다. -- `html` 하위 작업 요청이 있을 때는 해당 디렉터리 중심으로만 작업한다. +- 공식 HTML 시안은 기본적으로 `COMMON_DOCS_ROOT/작업계획/<작업폴더>/` 아래 단일 `.html` 파일로 만든다. +- `apps/sn/app/html` 작업은 사용자가 앱 라우트 기반 미리보기를 명시적으로 요청한 경우에만 사용한다. - `html` 하위 요청이라고 해서 `sn`의 실제 앱 코드 전체를 함께 수정하지 않는다. - `html` 작업과 실제 서비스 앱 로직 변경이 같이 필요해 보이면 먼저 분리해서 설명한다. @@ -174,6 +178,9 @@ Codex 작업 루트는 가능하면 `D:\workspace`로 잡고, 공통 시작 기 ## 빌드 및 검증 기준 +VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다. +개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다. + 이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다. ```sh @@ -229,8 +236,12 @@ pnpm turbo run build --filter="...[origin/main]" - 파일 인코딩은 UTF-8로 유지한다. - 한글 주석이 깨진 상태로 저장하지 않는다. +- `.md`, `.toml` 같은 한글 포함 텍스트 파일은 저장 직후 다시 열어 한글이 정상인지 확인한다. +- 한글이 많은 문서는 셸 리다이렉션(`>`, `>>`)이나 검증 없는 `Set-Content`, `Out-File`로 바로 덮어쓰지 않는다. - 기존 주석은 정당한 이유 없이 삭제하지 않는다. - 복잡한 렌더링 분기, 폼 처리, 데이터 변환, 트리 구조, 지도 연동, 표 컴포넌트 조합 로직은 함수/메서드 단위 주석을 상세히 작성한다. +- 신규/수정 상수와 변수는 이름만으로 의도가 충분히 드러나지 않으면 선언부 근처에 역할, 단위, 백엔드 계약, 화면 상태와의 관계를 주석으로 남긴다. +- `useRef`로 비동기 요청, 팝업 창, 지도 객체, 체크 상태를 보관하는 경우에는 왜 state가 아니라 ref를 쓰는지 주석으로 남긴다. --- @@ -240,13 +251,29 @@ pnpm turbo run build --filter="...[origin/main]" 권장 경로: -- `apps/sn/docs/` -- `apps/sn/docs/AI코딩하네스/` -- `apps/sn/docs/응답표준화/` +- `apps/sn/docs/기준문서/` +- `apps/sn/docs/작업계획/` - `apps/sn/docs/검증/` +공통 개발 흐름과 멀티에이전트 기준은 아래 공통 문서를 우선 참조한다. + +- `COMMON_DOCS_ROOT/기준문서/010-AI개발작업흐름.md` +- `COMMON_DOCS_ROOT/기준문서/011-멀티에이전트운영.md` + 1차 프로젝트 당시에는 AI 코딩 문서 체계를 사용하지 않았으므로, 신규 문서를 `phase2`처럼 별도 단계 폴더로 분리하지 않고 `apps/sn/docs/` 바로 아래 주제별 한글 폴더로 관리한다. +작업계획 문서는 아래 원칙으로 관리한다. + +- `apps/sn/docs/작업계획/작업카드-템플릿.md`는 최상위에 유지한다. +- 실제 기능 문서는 `apps/sn/docs/작업계획/<기능ID-기능명>/` 폴더 단위로 관리한다. +- 같은 기능의 FE 작업카드, 관련 메모, 추가 계획 문서는 같은 기능 폴더 안에 함께 둔다. +- 역할이 있는 문서 파일명은 `<순번>-<역할>-<프로젝트>-<기능명>-<문서종류>.md` 형식을 우선 사용한다. +- 날짜는 파일명 보조 정보로만 사용하고, `apps/sn/docs/작업계획/` 아래에 날짜 파일을 평면으로 계속 쌓지 않는다. +- 기존 `md` 문서의 경로와 파일명은 사용자 요청이나 승인된 구조 개편 없이 임의로 바꾸지 않는다. +- 기존 문서를 수정할 때는 현재 경로와 파일명을 유지한 채 내용을 갱신하는 것을 기본으로 한다. +- 공통 기준은 `COMMON_DOCS_ROOT/작업계획/작업계획-폴더규칙.md`를 함께 참고한다. +- UI/레이아웃 기준은 `FE_APP_DOCS_ROOT/기준문서/013-UI레이아웃가이드.md`를 함께 참고한다. + 문서 작업 시에도 `sn` 범위를 벗어나는 앱 문서는 임의로 수정하지 않는다. 루트 `docs/**`는 다른 회사/서비스 리소스를 포함할 수 있으므로 `sn` 작업 중 임의로 수정하지 않는다. 루트 `docs/**` 변경이 필요해 보이면 변경 이유, 영향 범위, `apps/sn/docs/**` 대체 가능 여부를 먼저 정리하고 사용자 승인을 받는다. diff --git a/apps/sn/app/(map-layout)/layout.tsx b/apps/sn/app/(map-layout)/layout.tsx index f58e67489..f840fa131 100644 --- a/apps/sn/app/(map-layout)/layout.tsx +++ b/apps/sn/app/(map-layout)/layout.tsx @@ -34,6 +34,14 @@ export default function MapLayout({ const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); + useEffect(() => { + document.body.classList.add("sn-map-page-lock"); + + return () => { + document.body.classList.remove("sn-map-page-lock"); + }; + }, []); + useEffect(() => { fetchCtpv(); fetchSgg(); diff --git a/apps/sn/app/(map-layout)/map/agedPipePopup/page.tsx b/apps/sn/app/(map-layout)/map/agedPipePopup/page.tsx new file mode 100644 index 000000000..22c9fa407 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/agedPipePopup/page.tsx @@ -0,0 +1,30 @@ +import { redirect } from "next/navigation"; + +type LegacyAgedPipePopupPageProps = { + searchParams: Promise>; +}; + +export default async function LegacyAgedPipePopupPage({ + searchParams, +}: LegacyAgedPipePopupPageProps) { + const resolvedSearchParams = await searchParams; + const nextSearchParams = new URLSearchParams(); + + Object.entries(resolvedSearchParams).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => nextSearchParams.append(key, item)); + return; + } + + if (typeof value === "string") { + nextSearchParams.set(key, value); + } + }); + + const queryString = nextSearchParams.toString(); + redirect( + queryString + ? `/popup/agedPipePopup?${queryString}` + : "/popup/agedPipePopup", + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx new file mode 100644 index 000000000..9162074f4 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { AgedPipeFilters } from "./agedPipeUtils"; + +type Props = { + filters: AgedPipeFilters; + onFiltersChange: (filters: AgedPipeFilters) => void; + onApply: (filters?: AgedPipeFilters) => void; +}; + +export default function AgedPipeLayerSettingPanel({ + filters, + onFiltersChange, + onApply, +}: Props) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? "")); + + useEffect(() => { + setElapsedYearsText(String(filters.elapsedYears ?? "")); + }, [filters.elapsedYears]); + + const commitElapsedYears = () => { + const nextFilters = { + ...filters, + elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText), + }; + onFiltersChange(nextFilters); + onApply(nextFilters); + }; + + return ( +
+
+ + 노후관로 설정 +
+ + {!isCollapsed && ( +
+
+ + event.currentTarget.select()} + onChange={(event) => { + const digits = event.target.value.replace(/[^0-9]/g, ""); + setElapsedYearsText(digits); + }} + onKeyDown={(event) => { + if (event.key === "Enter") commitElapsedYears(); + }} + /> + +
+
+ )} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx new file mode 100644 index 000000000..3626054c7 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button, Input } from "@common/components"; +import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { getData } from "@/components/lib/api"; +import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { + AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_EXCEL_HEADER, + AGED_PIPE_LAYER_TYPE, + AGED_PIPE_LIST_HEADLIST, + AGED_PIPE_PAGE_SIZE, + buildAgedPipeCqlFilters, + normalizeAgedPipeFilters, + toDisplayPipeRow, + validateElapsedYears, + type AgedPipeFilters, + type AgedPipeRow, +} from "./agedPipeUtils"; + +const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; +const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; +const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; + +export default function AgedPipePopupPage() { + const searchParams = useSearchParams(); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(() => + normalizeAgedPipeFilters({ + elapsedYears: Number(searchParams.get("elapsedYears") ?? AGED_PIPE_DEFAULT_FILTERS.elapsedYears), + ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd, + sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd, + }), + ); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + + const sggOptions = useMemo( + () => + filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))), + [filters.ctpvCd, sgg], + ); + + useEffect(() => { + if (ctpv.length === 0) fetchCtpv(); + if (sgg.length === 0) fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + const fetchRows = useCallback( + async (page: number, nextFilters: AgedPipeFilters, size: number) => { + const normalized = normalizeAgedPipeFilters(nextFilters); + const queryParams = new URLSearchParams({ + lyrTypeNm: AGED_PIPE_LAYER_TYPE, + page: String(page - 1), + size: String(size), + sort: "string", + }); + const cqlFilters = buildAgedPipeCqlFilters(normalized); + if (cqlFilters.length > 0) { + queryParams.append("cqlFiter", cqlFilters.join(" and ")); + } + + const response = await getData(`v1/fclts?${queryParams.toString()}`); + const responseRows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: responseRows.map((item: Record, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadRows = useCallback( + async (page = 1, nextFilters = filters) => { + setIsLoading(true); + try { + const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE); + setRows(response.rows); + setTotal(response.total); + setCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setRows([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, + [fetchRows, filters], + ); + + useEffect(() => { + loadRows(1, filters); + // 최초 로딩만 수행한다. 이후 조회는 버튼/페이지 변경으로 처리한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const handleFilterSyncMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== POPUP_FILTER_SYNC_MESSAGE) return; + if (!event.data?.filters) return; + + const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters); + setFilters(normalized); + setSelectedIndex(-1); + loadRows(1, normalized); + }; + + window.addEventListener("message", handleFilterSyncMessage); + return () => window.removeEventListener("message", handleFilterSyncMessage); + }, [loadRows]); + + const handleSearch = (page = 1) => { + if (!validateElapsedYears(Number(filters.elapsedYears))) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + + const normalized = normalizeAgedPipeFilters(filters); + setFilters(normalized); + loadRows(page, normalized); + }; + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + setSelectedIndex(index); + window.opener?.postMessage( + { + type: POPUP_ROW_SELECT_MESSAGE, + row, + }, + window.location.origin, + ); + }; + + const handleDownload = async () => { + if (total === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + setIsLoading(true); + const response = await fetchRows(1, filters, 10000); + await downloadExcelWithKoreanHeaders({ + data: response.rows as JSON[], + fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, + sheetNameArray: ["노후관로 현황"], + headerRange: "A1:J1", + headerMapping: AGED_PIPE_EXCEL_HEADER, + }); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleMinimize = () => { + window.opener?.postMessage( + { + type: POPUP_MINIMIZE_MESSAGE, + filters: normalizeAgedPipeFilters(filters), + total, + }, + window.location.origin, + ); + window.close(); + }; + + return ( +
+ +
+

노후관로 현황

+
+ +
+
+ +
+
+ + + + + + +
+ + +
+
+ + + headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={AGED_PIPE_LIST_HEADLIST} + pageSize={10} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => handleSearch(page)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + /> +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts new file mode 100644 index 000000000..62bccc416 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -0,0 +1,236 @@ +"use client"; + +import type { SelectDataType } from "@common/business_components"; +import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; +import { + MS_SWAGE_PPL_CODES, + MS_SWAGE_PPL_USE_CODES, +} from "../common/table"; + +export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; +export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30; +export const AGED_PIPE_PAGE_SIZE = 10; +export const AGED_PIPE_MIN_ZOOM = 14; +export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"]; + +export type AgedPipeFilters = { + elapsedYears: number; + ctpvCd: string; + sggCd: string; +}; + +export type AgedPipeRow = { + fid: string; + lyrTypeNm: string; + rowNumber: number; + fcltMngNo: string; + instlYmd: string; + pdmtVl: string | number; + prlgVl: string | number; + usgCd: string; + mqltCd: string; + elapsedYears: number | string; + ctpvNm?: string; + sggNm?: string; + [key: string]: unknown; +}; + +type PipeSource = Record; + +const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text])); +const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text])); + +export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = { + elapsedYears: AGED_PIPE_DEFAULT_ELAPSED_YEARS, + ctpvCd: "0", + sggCd: "0", +}; + +export const AGED_PIPE_LIST_HEADLIST = [ + { id: "rowNumber", title: "NO", widthPercent: 6 }, + { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, + { id: "instlYmd", title: "설치일자", widthPercent: 12 }, + { id: "pdmtVl", title: "관경(mm)", widthPercent: 10 }, + { id: "prlgVl", title: "연장(m)", widthPercent: 10 }, + { id: "usgCd", title: "용도", widthPercent: 14 }, + { id: "mqltCd", title: "재질", widthPercent: 18 }, + { id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" }, +]; + +export const AGED_PIPE_EXCEL_HEADER = { + rowNumber: "NO", + fcltMngNo: "시설관리번호", + instlYmd: "설치일자", + pdmtVl: "관경(mm)", + prlgVl: "연장(m)", + usgCd: "용도", + mqltCd: "재질", + elapsedYears: "경과년도", + ctpvNm: "시도", + sggNm: "시군구", +}; + +const pad2 = (value: number) => String(value).padStart(2, "0"); + +const normalizeYmd = (value: unknown) => + String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); + +const getToday = () => new Date(); + +export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilters => { + const elapsedYears = Math.trunc(Number(filters.elapsedYears)); + + return { + ...filters, + elapsedYears: + Number.isFinite(elapsedYears) && elapsedYears >= 1 && elapsedYears <= 100 + ? elapsedYears + : AGED_PIPE_DEFAULT_ELAPSED_YEARS, + }; +}; + +export const validateElapsedYears = (value: number) => + Number.isInteger(value) && value >= 1 && value <= 100; + +export const getCutoffYmd = (elapsedYears: number) => { + const today = getToday(); + const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate()); + return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`; +}; + +export const getElapsedYears = (value: unknown) => { + const normalized = normalizeYmd(value); + if (normalized.length < 4 || isInvalidInstallYmd(value)) return null; + + const year = Number(normalized.slice(0, 4)); + const month = Number(normalized.slice(4, 6) || "1"); + const day = Number(normalized.slice(6, 8) || "1"); + if (!year) return null; + + const today = getToday(); + let elapsed = today.getFullYear() - year; + if ( + today.getMonth() + 1 < month || + (today.getMonth() + 1 === month && today.getDate() < day) + ) { + elapsed -= 1; + } + + return Math.max(elapsed, 0); +}; + +export const isInvalidInstallYmd = (value: unknown) => { + const normalized = normalizeYmd(value); + return ( + normalized.length < 8 || + AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.some((prefix) => normalized.startsWith(prefix)) + ); +}; + +export const getElapsedYearsDisplayText = (value: unknown) => { + const elapsedYears = getElapsedYears(value); + return elapsedYears == null ? "확인불가" : `${elapsedYears}년`; +}; + +export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { + const normalized = normalizeAgedPipeFilters(filters); + const cqlFilters = [ + "instl_ymd IS NOT NULL", + ...AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.map((prefix) => `instl_ymd NOT LIKE '${prefix}%'`), + `instl_ymd <= '${getCutoffYmd(normalized.elapsedYears)}'`, + ]; + + if (normalized.sggCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.sggCd}%'`); + } else if (normalized.ctpvCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.ctpvCd}%'`); + } + + return cqlFilters; +}; + +const getRegionCode = (source: PipeSource) => + String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim(); + +export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => { + const normalized = normalizeAgedPipeFilters(filters); + const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd); + if (!instlYmd || isInvalidInstallYmd(instlYmd) || instlYmd > getCutoffYmd(normalized.elapsedYears)) return false; + + const regionCode = getRegionCode(source); + if (normalized.sggCd !== "0") return regionCode.startsWith(normalized.sggCd); + if (normalized.ctpvCd !== "0") return regionCode.startsWith(normalized.ctpvCd); + return true; +}; + +const resolveRegionName = ( + code: string, + directName: unknown, + options: SelectDataType[], + prefixLength: number, +) => { + const direct = String(directName ?? "").trim(); + if (direct) return direct; + + const prefix = code.slice(0, prefixLength); + return options.find((item) => String(item.val).trim() === prefix)?.text ?? ""; +}; + +export const toDisplayPipeRow = ( + source: PipeSource, + index: number, + page: number, + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], +): AgedPipeRow => { + const formatted = formatDisplayData(source) as PipeSource; + const codePrefix = getRegionCode(source); + const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd; + const invalidInstallYmd = isInvalidInstallYmd(source.instlYmd ?? source.instl_ymd ?? instlYmd); + const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim(); + const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim(); + const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-"; + const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-"; + const fcltMngNo = String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"); + + return { + ...source, + ...formatted, + fid: String(source.fid ?? ""), + lyrTypeNm: String(source.lyrTypeNm ?? AGED_PIPE_LAYER_TYPE), + rowNumber: (page - 1) * AGED_PIPE_PAGE_SIZE + index + 1, + fcltMngNo, + swtrmtFcltCd: source.swtrmtFcltCd ?? fcltMngNo, + instlYmd: invalidInstallYmd ? "확인불가" : String(instlYmd ?? "-"), + pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl), + prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl), + usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"), + mqltCd: + materialMap.get(trimmedMaterial) ?? + String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"), + elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "확인불가", + ctpvNm: resolveRegionName( + codePrefix, + formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm, + ctpvOptions, + 2, + ), + sggNm: resolveRegionName( + codePrefix, + formatted.sggNm ?? source.sggNm ?? source.sgg_nm, + sggOptions, + 5, + ), + }; +}; + +export const isAgedPipeLayerNode = (node: any) => { + const data = node?.data ?? node; + const layerName = String(data?.lyrNm ?? node?.text ?? ""); + const layerTypeName = String(data?.lyrTyNm ?? ""); + + return ( + layerTypeName === "tb_old_pipe" || + layerName.includes("노후관로") + ); +}; diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx new file mode 100644 index 000000000..f1d2590bc --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; + +type Props = { + onOpenPopup: (popupKey: WindowPopupKey) => void; +}; + +const ANALYSIS_POPUP_ITEMS: Array<{ key: WindowPopupKey; label: string }> = [ + { key: "pipeAnalysisPopup", label: "관로분석" }, + { key: "floodPredictionMapPopup", label: "침수예측지도" }, + { key: "floodHistoryPopup", label: "침수발생이력" }, + { key: "gisEditPopup", label: "GIS 편집" }, + { key: "gisManagementPopup", label: "GIS 관리" }, + { key: "flowRiskPopup", label: "통수 위험" }, +]; + +export default function AnalysisPopupMenu({ onOpenPopup }: Props) { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+
+ {isOpen ? ( +
+ {ANALYSIS_POPUP_ITEMS.map((item) => ( + + ))} +
+ ) : null} + + +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/common/index.ts b/apps/sn/app/(map-layout)/map/components/common/index.ts index d2aed5956..dd20952b3 100644 --- a/apps/sn/app/(map-layout)/map/components/common/index.ts +++ b/apps/sn/app/(map-layout)/map/components/common/index.ts @@ -4,6 +4,7 @@ import smh from "../popupComponent/smh"; import Ssc from "../popupComponent/Ssc"; import StormWaterPump from "../popupComponent/stormWaterPump"; import SwtrmtFclt from "../popupComponent/SwtrmtFclt"; +import SwtrmtPrcsZoneAnalysis from "../popupComponent/SwtrmtPrcsZoneAnalysis"; type LayerComponentProps = { featureData: any; @@ -15,6 +16,7 @@ type LayerComponentProps = { chmbrXcnts: string; chmbrYdnts: string; gisObject?: Record; // GIS 객체 추가 + rawFeatureData?: any; }; type LayerContentMap = { [key: string]: { @@ -26,7 +28,7 @@ type LayerContentMap = { // 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업 const layerContentMap: LayerContentMap = { "하수처리시설": { component: SwtrmtFclt, }, // 디지털 자료관 연계정보가 없으니 일단 주석 처리 - "하수처리구역": {}, + "하수처리구역": { component: SwtrmtPrcsZoneAnalysis }, "우수토실": { component: Ssc, }, "중점우수토실": { component: Ssc, }, // 중점우수토실 여부 설정 "하수관로": { component: MsSwagePpl, }, @@ -65,4 +67,4 @@ export const handleInputChange = (section: string, updated[index][field as keyof typeof updated[number]] = value; } target.setData(updated); -}; \ No newline at end of file +}; diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index 4e501fb20..81e14a83f 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -3,6 +3,7 @@ export const MsSwagePplCol = [ { id: 'fclt_mng_no', title: '시설관리번호' }, { id: 'inst_mng_no', title: '기관관리번호' }, { id: 'instl_ymd', title: '설치일자' }, + { id: 'elapsedYears', title: '경과년도' }, { id: 'mapsht_no', title: '도엽번호' }, { id: 'pdmt_vl', title: '관경(mm)' }, { id: 'prlg_vl', title: '연장(m)' }, diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx new file mode 100644 index 000000000..200405c9a --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button, Input } from "@common/components"; +import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { getData } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import useStore from "@/store/useStore"; +import { useUserStore } from "@/shared/lib/use-user-store"; +import { + PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS, + PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS, + PIPE_ANALYSIS_DETAIL_HEADLIST, + PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, + applyScopeToDetailFilters, + buildPipeAnalysisFileDate, + buildPipeAnalysisScope, + normalizeNumberInput, + resolveElapsedYearBucketLabel, + resolveMaterialName, + resolvePipeAnalysisTypeLabel, + resolveRegionName, + toPipeAnalysisDetailViewRows, + type PipeAnalysisDetailFilters, + type PipeAnalysisDetailRow, +} from "./pipeAnalysisUtils"; + +type DetailViewRow = ReturnType[number]; + +export default function PipeAnalysisDetailPopupPage() { + const searchParams = useSearchParams(); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const userInfo = useUserStore((state) => state.userInfo); + const scope = useMemo(() => buildPipeAnalysisScope(userInfo), [userInfo]); + + const baseAnalysisType = searchParams.get("analysisType") ?? "YEAR"; + const baseElapsedYearBucket = searchParams.get("elapsedYearBucket") ?? ""; + const baseMaterialCode = searchParams.get("materialCode") ?? "0"; + const [filters, setFilters] = useState(() => + applyScopeToDetailFilters( + { + ...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS, + ctpvCd: searchParams.get("ctpvCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd, + sggCd: searchParams.get("sggCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd, + swtrmtZoneNm: searchParams.get("swtrmtZoneNm") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm, + materialCode: baseMaterialCode, + }, + scope, + ), + ); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + const initializedRef = useRef(false); + + useEffect(() => { + if (ctpv.length === 0) fetchCtpv(); + if (sgg.length === 0) fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + useEffect(() => { + setFilters((current) => applyScopeToDetailFilters(current, scope)); + }, [scope]); + + const fetchRows = useCallback( + async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number) => { + const params = new URLSearchParams({ + analysisType: baseAnalysisType, + page: String(page - 1), + size: String(size), + }); + + if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd); + if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd); + if (nextFilters.swtrmtZoneNm !== "0") params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm); + if (baseElapsedYearBucket) params.set("elapsedYearBucket", baseElapsedYearBucket); + if (nextFilters.materialCode !== "0") params.set("materialCode", nextFilters.materialCode); + if (nextFilters.useCd !== "0") params.set("useCd", nextFilters.useCd); + if (nextFilters.diameterFrom) params.set("diameterFrom", nextFilters.diameterFrom); + if (nextFilters.diameterTo) params.set("diameterTo", nextFilters.diameterTo); + if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode); + + const response = await getData(`v1/fclts/ppl-analysis/details?${params.toString()}`); + const responseRows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: toPipeAnalysisDetailViewRows(responseRows as PipeAnalysisDetailRow[], ctpv, sgg), + total: Number(response?.count ?? 0), + }; + }, + [baseAnalysisType, baseElapsedYearBucket, ctpv, scope.regionCode, sgg], + ); + + const loadRows = useCallback( + async (page = 1, nextFilters = filters) => { + setIsLoading(true); + try { + const response = await fetchRows(page, nextFilters, 20); + setRows(response.rows); + setTotal(response.total); + setCurrentPage(page); + } catch (error) { + console.error("관로현황 조회 중 오류:", error); + alert("관로현황 조회 중 오류가 발생했습니다."); + setRows([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, + [fetchRows, filters], + ); + + useEffect(() => { + if (initializedRef.current) return; + if (ctpv.length === 0 || sgg.length === 0) return; + initializedRef.current = true; + void loadRows(1, applyScopeToDetailFilters(filters, scope)); + }, [ctpv.length, filters, loadRows, scope, sgg.length]); + + const handleSearch = (page = 1) => { + setSelectedIndex(-1); + void loadRows(page, applyScopeToDetailFilters(filters, scope)); + }; + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + setSelectedIndex(index); + const targetWindow = window.opener?.opener ?? window.opener; + targetWindow?.postMessage( + { + type: PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, + row, + }, + window.location.origin, + ); + }; + + const handleDownload = async () => { + if (total === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + setIsLoading(true); + const response = await fetchRows(1, filters, Math.max(total, 1000)); + await downloadExcelWithKoreanHeaders({ + data: response.rows as unknown as JSON[], + fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`, + sheetNameArray: ["관로현황"], + headerRange: "A1:K1", + headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS, + }); + } catch (error) { + console.error("관로현황 다운로드 중 오류:", error); + alert("관로현황 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const materialOptions = useMemo(() => { + const options = [ + { value: "0", label: "전체" }, + ...Array.from( + new Set( + rows + .map((row) => String(row.materialCode ?? "").trim()) + .filter((value) => value && value !== "0"), + ), + ).map((value) => ({ + value, + label: resolveMaterialName(value), + })), + ]; + + if ( + baseMaterialCode && + baseMaterialCode !== "0" && + !options.some((item) => item.value === baseMaterialCode) + ) { + options.push({ + value: baseMaterialCode, + label: resolveMaterialName(baseMaterialCode), + }); + } + + return options; + }, [baseMaterialCode, rows]); + + const titleSuffix = useMemo(() => { + const locationParts: string[] = []; + const resolvedCtpvName = rows[0]?.ctpvNm || resolveRegionName(filters.ctpvCd, ctpv, 2); + const resolvedSggName = rows[0]?.sggNm || resolveRegionName(filters.sggCd, sgg, 5); + const resolvedZoneName = + (filters.swtrmtZoneNm && filters.swtrmtZoneNm !== "0" ? filters.swtrmtZoneNm : rows[0]?.swtrmtZoneNm) || ""; + + if (resolvedCtpvName && resolvedCtpvName !== "-") locationParts.push(resolvedCtpvName); + if (resolvedSggName && resolvedSggName !== "-") locationParts.push(resolvedSggName); + if (resolvedZoneName && resolvedZoneName !== "-" && resolvedZoneName !== "0") locationParts.push(resolvedZoneName); + + const parts = []; + if (locationParts.length > 0) parts.push(locationParts.join(" ")); + const analysisTypeLabel = resolvePipeAnalysisTypeLabel(baseAnalysisType); + if (analysisTypeLabel) parts.push(analysisTypeLabel); + if (baseElapsedYearBucket) parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket)); + if (baseMaterialCode && baseMaterialCode !== "0") parts.push(resolveMaterialName(baseMaterialCode)); + return parts.length > 0 ? ` (${parts.join(" / ")})` : ""; + }, [baseAnalysisType, baseElapsedYearBucket, baseMaterialCode, ctpv, filters.ctpvCd, filters.sggCd, filters.swtrmtZoneNm, rows, sgg]); + + return ( +
+ +
+

관로현황{titleSuffix}

+
+ +
+
+ + + + + + + + +
+ + +
+
+ + + headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={PIPE_ANALYSIS_DETAIL_HEADLIST} + pageSize={20} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => handleSearch(page)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + /> +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx new file mode 100644 index 000000000..97cb703cd --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -0,0 +1,526 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components"; +import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { getData } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import useStore from "@/store/useStore"; +import { useUserStore } from "@/shared/lib/use-user-store"; +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { + PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, + PIPE_ANALYSIS_TYPE_OPTIONS, + applyScopeToSummaryFilters, + buildPipeAnalysisFileDate, + buildPipeAnalysisYearMaterialGroups, + buildPipeAnalysisScope, + buildPipeAnalysisSummaryDownloadHeaders, + buildPipeAnalysisSummaryHeadList, + buildPipeAnalysisSummaryViewRows, + fetchPipeAnalysisZoneOptions, + resolvePipeAnalysisTypeLabel, + type PipeAnalysisMaterialColumn, + type PipeAnalysisSummaryFilters, + type PipeAnalysisSummaryResponse, + type PipeAnalysisSummaryViewRow, + type PipeAnalysisYearMaterialGroup, +} from "./pipeAnalysisUtils"; + +const SUMMARY_FILE_PREFIX: Record = { + YEAR: "관로분석_경과년도별", + MATERIAL: "관로분석_관재질별", + YEAR_MATERIAL: "관로분석_경과년도별_관재질별", +}; + +const toExcelColumnName = (index: number) => { + let current = index; + let result = ""; + + while (current > 0) { + const remainder = (current - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + current = Math.floor((current - 1) / 26); + } + + return result || "A"; +}; + +const formatGridNumber = (value: unknown) => { + const num = Number(value ?? 0); + if (!Number.isFinite(num)) { + return String(value ?? ""); + } + return num.toLocaleString("ko-KR", { + minimumFractionDigits: 0, + maximumFractionDigits: 3, + }); +}; + +export default function PipeAnalysisPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const userInfo = useUserStore((state) => state.userInfo); + const scope = useMemo(() => buildPipeAnalysisScope(userInfo), [userInfo]); + const [draftFilters, setDraftFilters] = useState(() => + applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope), + ); + const [appliedFilters, setAppliedFilters] = useState(() => + applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope), + ); + const [materialColumns, setMaterialColumns] = useState([]); + const [rows, setRows] = useState([]); + const [zoneOptions, setZoneOptions] = useState>([]); + const [isLoading, setIsLoading] = useState(false); + const initializedRef = useRef(false); + + useEffect(() => { + if (ctpv.length === 0) fetchCtpv(); + if (sgg.length === 0) fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + useEffect(() => { + setDraftFilters((current) => applyScopeToSummaryFilters(current, scope)); + setAppliedFilters((current) => applyScopeToSummaryFilters(current, scope)); + }, [scope]); + + useEffect(() => { + let mounted = true; + + (async () => { + try { + const zones = await fetchPipeAnalysisZoneOptions({ + ctpvCd: draftFilters.ctpvCd, + sggCd: draftFilters.sggCd, + scopeRegionCode: scope.regionCode, + }); + if (!mounted) return; + setZoneOptions([{ value: "0", label: "전체" }, ...zones]); + } catch (error) { + console.error("처리구역 목록 조회 중 오류:", error); + if (!mounted) return; + setZoneOptions([{ value: "0", label: "전체" }]); + } + })(); + + return () => { + mounted = false; + }; + }, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode]); + + const fetchSummary = useCallback( + async (nextFilters: PipeAnalysisSummaryFilters) => { + const params = new URLSearchParams({ + analysisType: nextFilters.analysisType, + }); + + if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd); + if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd); + if (nextFilters.swtrmtZoneNm !== "0") params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm); + if (nextFilters.scopeRegionCode) params.set("scopeRegionCode", nextFilters.scopeRegionCode); + + const response = (await getData( + `v1/fclts/ppl-analysis/summaries?${params.toString()}`, + )) as PipeAnalysisSummaryResponse; + + const nextMaterialColumns = Array.isArray(response?.materialColumns) + ? response.materialColumns + : []; + setMaterialColumns(nextMaterialColumns); + setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg)); + }, + [ctpv, sgg], + ); + + const handleSearch = useCallback( + async (nextFilters = draftFilters) => { + setIsLoading(true); + try { + const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope); + setDraftFilters(scopedFilters); + setAppliedFilters(scopedFilters); + await fetchSummary(scopedFilters); + } catch (error) { + console.error("관로분석 집계 조회 중 오류:", error); + alert("관로분석 집계 조회 중 오류가 발생했습니다."); + setRows([]); + setMaterialColumns([]); + } finally { + setIsLoading(false); + } + }, + [draftFilters, fetchSummary, scope], + ); + + useEffect(() => { + if (initializedRef.current) return; + if (ctpv.length === 0 || sgg.length === 0) return; + + initializedRef.current = true; + void handleSearch(applyScopeToSummaryFilters(draftFilters, scope)); + }, [ctpv.length, draftFilters, handleSearch, scope, sgg.length]); + + const handleDownload = async () => { + if (rows.length === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + const exportHeadList = buildPipeAnalysisSummaryHeadList( + appliedFilters.analysisType, + materialColumns, + ); + const headerRange = `A1:${toExcelColumnName(exportHeadList.length)}1`; + const mergeRanges = appliedFilters.analysisType === "YEAR_MATERIAL" + ? (() => { + const ranges: string[] = []; + let currentRow = 2; + + yearMaterialGroups.forEach((group) => { + const rowSpan = group.rows.length; + if (rowSpan > 1) { + ["A", "B", "C", "D", "E"].forEach((column) => { + ranges.push(`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`); + }); + } + currentRow += rowSpan; + }); + + return ranges; + })() + : []; + const exportRows = appliedFilters.analysisType === "YEAR_MATERIAL" + ? yearMaterialGroups.flatMap((group) => + group.rows.map((yearRow, rowIndex) => { + const exportRow: Record = { + rowNo: rowIndex === 0 ? group.rowNo : "", + ctpvNm: rowIndex === 0 ? group.ctpvNm : "", + sggNm: rowIndex === 0 ? group.sggNm : "", + swtrmtZoneNm: rowIndex === 0 ? group.swtrmtZoneNm : "", + totalLengthKm: rowIndex === 0 ? group.totalLengthKm : "", + elapsedYearLabel: yearRow.elapsedYearLabel, + }; + + materialColumns.forEach((column) => { + exportRow[column.key] = yearRow.metrics[column.key] ?? 0; + }); + + return exportRow; + }), + ) + : rows.map((row) => { + const exportRow: Record = {}; + exportHeadList.forEach((head) => { + exportRow[head.id] = row[head.id]; + }); + return exportRow; + }); + await downloadExcelWithKoreanHeaders({ + data: exportRows as JSON[], + fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`, + sheetNameArray: ["관로분석 현황"], + headerRange, + headerMapping: buildPipeAnalysisSummaryDownloadHeaders(appliedFilters.analysisType, materialColumns), + mergeRanges, + }); + } catch (error) { + console.error("관로분석 집계 다운로드 중 오류:", error); + alert("관로분석 집계 다운로드 중 오류가 발생했습니다."); + } + }; + + const handleCellClick = (index?: number, columnId?: string) => { + if (typeof index !== "number" || !columnId) return; + const row = rows[index]; + if (!row) return; + if (row.__isNoZone || row.__isSummaryTotal) return; + const cellFilter = row.__cellFilters[columnId]; + if (!cellFilter) return; + + openWindowPopup("pipeAnalysisDetailPopup", { + analysisType: appliedFilters.analysisType, + ctpvCd: String(row.__base.ctpvCd || appliedFilters.ctpvCd), + sggCd: String(row.__base.sggCd || appliedFilters.sggCd || "0"), + swtrmtZoneNm: String(row.__base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm), + elapsedYearBucket: cellFilter.elapsedYearBucket ?? "", + materialCode: cellFilter.materialCode ?? "", + scopeRegionCode: scope.regionCode, + }); + }; + + const handleYearMaterialCellClick = ( + group: PipeAnalysisYearMaterialGroup, + row: PipeAnalysisYearMaterialGroup["rows"][number], + columnId: string, + ) => { + const cellFilter = row.cellFilters[columnId]; + if (!cellFilter) return; + + openWindowPopup("pipeAnalysisDetailPopup", { + analysisType: appliedFilters.analysisType, + ctpvCd: String(group.base.ctpvCd || appliedFilters.ctpvCd), + sggCd: String(group.base.sggCd || appliedFilters.sggCd || "0"), + swtrmtZoneNm: String(group.base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm), + elapsedYearBucket: cellFilter.elapsedYearBucket ?? "", + materialCode: cellFilter.materialCode ?? "", + scopeRegionCode: scope.regionCode, + }); + }; + + const headList = useMemo( + () => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns), + [appliedFilters.analysisType, materialColumns], + ); + + const yearMaterialGroups = useMemo( + () => appliedFilters.analysisType === "YEAR_MATERIAL" + ? buildPipeAnalysisYearMaterialGroups(rows, materialColumns) + : [], + [appliedFilters.analysisType, materialColumns, rows], + ); + + const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR"; + + const sggOptions = useMemo( + () => + draftFilters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2))), + [draftFilters.ctpvCd, sgg], + ); + + return ( +
+ +
+

관로분석

+
+ +
+
+ + + + + + + + +
+ + +
+
+ + {appliedFilters.analysisType === "YEAR_MATERIAL" ? ( + <> +
+

+ 寃쎄낵?꾨룄&?ъ쭏 愿€濡쒗쁽?? 珥?{yearMaterialGroups.length.toLocaleString("ko-KR")}嫄? +

+

+ {resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건 +

+
+
+ + + + {headList.map((head) => ( + + {head.title} + + ))} + + + + {yearMaterialGroups.length > 0 ? ( + yearMaterialGroups.flatMap((group) => + group.rows.map((yearRow, rowIndex) => ( + + {rowIndex === 0 ? ( + <> + + {group.rowNo} + + + {group.ctpvNm} + + + {group.sggNm} + + + {group.swtrmtZoneNm} + + + {formatGridNumber(group.totalLengthKm)} + + + ) : null} + + {yearRow.elapsedYearLabel} + + {materialColumns.map((column) => { + const value = Number(yearRow.metrics[column.key] ?? 0); + const isClickable = value > 0 && Boolean(yearRow.cellFilters[column.key]); + return ( + { + if (isClickable) { + handleYearMaterialCellClick(group, yearRow, column.key); + } + }} + > + {formatGridNumber(value)} + + ); + })} + + )) + ) + ) : ( + + + 議고쉶??寃곌낵媛€ ?놁뒿?덈떎. + + + )} + +
+
+ + ) : null} + + + className={appliedFilters.analysisType === "YEAR_MATERIAL" + ? "hidden" + : useHorizontalScrollTable + ? "[&>div.relative.w-full]:overflow-x-auto [&>div.relative.w-full]:overflow-y-visible" + : ""} + headName={ + appliedFilters.analysisType === "YEAR_MATERIAL" + ? "경과년도&재질 관로현황" + : "관로분석현황" + } + tableData={rows} + headlist={headList} + pageSize={Math.max(rows.length, 1)} + total={rows.length} + cellClick={(index, columnId) => handleCellClick(index, columnId)} + onPageChange={() => undefined} + curPage={1} + widthFixed={!useHorizontalScrollTable} + cellClassName={(row, columnId) => + !row.__isNoZone && row.__cellFilters[columnId] + ? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2" + : "" + } + /> +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts new file mode 100644 index 000000000..7eeaa934b --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts @@ -0,0 +1,628 @@ +"use client"; + +import type { SelectDataType } from "@common/business_components"; +import type { IUserInfo } from "@/shared/store"; +import { + MS_SWAGE_PPL_CODES, + MS_SWAGE_PPL_USE_CODES, +} from "../common/table"; + +export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE = "SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT"; + +export type PipeAnalysisType = "YEAR" | "MATERIAL" | "YEAR_MATERIAL"; + +export type PipeAnalysisSummaryFilters = { + analysisType: PipeAnalysisType; + ctpvCd: string; + sggCd: string; + swtrmtZoneNm: string; + scopeRegionCode: string; +}; + +export type PipeAnalysisDetailFilters = { + ctpvCd: string; + sggCd: string; + swtrmtZoneNm: string; + useCd: string; + materialCode: string; + diameterFrom: string; + diameterTo: string; +}; + +export type PipeAnalysisMaterialColumn = { + key: string; + materialCode: string; + materialName: string; +}; + +export type PipeAnalysisSummaryRow = { + rowNo: number; + ctpvCd: string; + ctpvNm?: string; + sggCd: string; + sggNm?: string; + swtrmtZoneNm: string; + totalLengthKm: number; + elapsedYearBucket?: string; + elapsedYearLabel?: string; + metrics: Record; +}; + +export type PipeAnalysisSummaryResponse = { + analysisType: PipeAnalysisType; + materialColumns: PipeAnalysisMaterialColumn[]; + data: PipeAnalysisSummaryRow[]; +}; + +export type PipeAnalysisDetailRow = { + rowNo: number; + fid: string; + lyrTypeNm: string; + fcltMngNo: string; + instMngNo: string; + mngInstCd: string; + ctpvCd: string; + ctpvNm?: string; + sggCd: string; + sggNm?: string; + swtrmtZoneNm: string; + instlYmd: string; + elapsedYears?: number; + useCd: string; + materialCode: string; + pdmtVl?: number | string; + prlgVl?: number | string; +}; + +export type PipeAnalysisZoneOption = { + value: string; + label: string; + ctpvCd: string; + sggCd: string; +}; + +export type PipeAnalysisScope = { + regionCode: string; + ctpvCd: string; + sggCd: string; + ctpvLocked: boolean; + sggLocked: boolean; +}; + +export type PipeAnalysisCellFilter = { + elapsedYearBucket?: string; + materialCode?: string; +}; + +export type PipeAnalysisSummaryViewRow = Record & { + __isNoZone: boolean; + __isSummaryTotal: boolean; + __cellFilters: Record; + __base: { + ctpvCd: string; + sggCd: string; + swtrmtZoneNm: string; + }; +}; + +export type PipeAnalysisYearMaterialGroupRow = { + elapsedYearBucket: string; + elapsedYearLabel: string; + metrics: Record; + cellFilters: Record; +}; + +export type PipeAnalysisYearMaterialGroup = { + rowNo: number; + ctpvNm: string; + sggNm: string; + swtrmtZoneNm: string; + totalLengthKm: number; + base: { + ctpvCd: string; + sggCd: string; + swtrmtZoneNm: string; + }; + rows: PipeAnalysisYearMaterialGroupRow[]; +}; + +const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text])); +const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text])); + +export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{ value: PipeAnalysisType; label: string }> = [ + { value: "YEAR", label: "경과년도별" }, + { value: "MATERIAL", label: "재질별" }, + { value: "YEAR_MATERIAL", label: "경과년도&재질" }, +]; + +export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters = { + analysisType: "YEAR", + ctpvCd: "0", + sggCd: "0", + swtrmtZoneNm: "0", + scopeRegionCode: "", +}; + +export const PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS: PipeAnalysisDetailFilters = { + ctpvCd: "0", + sggCd: "0", + swtrmtZoneNm: "0", + useCd: "0", + materialCode: "0", + diameterFrom: "", + diameterTo: "", +}; + +export const PIPE_ANALYSIS_FIXED_YEAR_COLUMNS = [ + { key: "LT10", title: "10년 미만(km)" }, + { key: "Y10_20", title: "10년~20년(km)" }, + { key: "Y20_30", title: "20년~30년(km)" }, + { key: "GE30", title: "30년 이상(km)" }, + { key: "UNKNOWN", title: "확인 불가(km)" }, +]; + +const elapsedYearBucketLabelMap: Record = { + LT10: "10년 미만", + Y10_20: "10년~20년", + Y20_30: "20년~30년", + GE30: "30년 이상", + UNKNOWN: "확인 불가", +}; + +const YEAR_MATERIAL_BASE_BUCKETS = ["LT10", "Y10_20", "Y20_30", "GE30"]; + +export const PIPE_ANALYSIS_DETAIL_HEADLIST = [ + { id: "rowNo", title: "NO", widthPercent: 6 }, + { id: "ctpvNm", title: "시도", widthPercent: 9 }, + { id: "sggNm", title: "시군구", widthPercent: 10 }, + { id: "fcltMngNo", title: "시설관리번호", widthPercent: 15 }, + { id: "instMngNo", title: "기관관리번호", widthPercent: 15 }, + { id: "swtrmtZoneNm", title: "처리구역", widthPercent: 12 }, + { id: "instlYmd", title: "설치일자", widthPercent: 10 }, + { id: "useNm", title: "용도", widthPercent: 9 }, + { id: "pdmtVl", title: "관경(mm)", widthPercent: 8, align: "right" }, + { id: "prlgVl", title: "연장(m)", widthPercent: 8, align: "right" }, + { id: "materialName", title: "관재질", widthPercent: 12 }, +]; + +export const buildPipeAnalysisScope = (userInfo: IUserInfo | null): PipeAnalysisScope => { + const admin = userInfo?.adminRoleYn === "Y"; + if (admin) { + return { + regionCode: "", + ctpvCd: "", + sggCd: "", + ctpvLocked: false, + sggLocked: false, + }; + } + + const digits = String(userInfo?.laeSeCd ?? "").replace(/[^0-9]/g, ""); + if (digits.length >= 5) { + return { + regionCode: digits.slice(0, 5), + ctpvCd: digits.slice(0, 2), + sggCd: digits.slice(0, 5), + ctpvLocked: true, + sggLocked: true, + }; + } + if (digits.length >= 2) { + return { + regionCode: digits.slice(0, 2), + ctpvCd: digits.slice(0, 2), + sggCd: "", + ctpvLocked: true, + sggLocked: false, + }; + } + + return { + regionCode: "", + ctpvCd: "", + sggCd: "", + ctpvLocked: false, + sggLocked: false, + }; +}; + +export const applyScopeToSummaryFilters = ( + filters: PipeAnalysisSummaryFilters, + scope: PipeAnalysisScope, +): PipeAnalysisSummaryFilters => { + const next = { ...filters, scopeRegionCode: scope.regionCode }; + if (scope.ctpvCd) { + next.ctpvCd = scope.ctpvCd; + } + if (scope.sggCd) { + next.sggCd = scope.sggCd; + } else if (scope.ctpvCd && !next.sggCd.startsWith(scope.ctpvCd)) { + next.sggCd = "0"; + } + return next; +}; + +export const applyScopeToDetailFilters = ( + filters: PipeAnalysisDetailFilters, + scope: PipeAnalysisScope, +): PipeAnalysisDetailFilters => { + const next = { ...filters }; + if (scope.ctpvCd) { + next.ctpvCd = scope.ctpvCd; + } + if (scope.sggCd) { + next.sggCd = scope.sggCd; + } else if (scope.ctpvCd && !next.sggCd.startsWith(scope.ctpvCd)) { + next.sggCd = "0"; + } + return next; +}; + +export const buildPipeAnalysisSummaryHeadList = ( + analysisType: PipeAnalysisType, + materialColumns: PipeAnalysisMaterialColumn[], +) => { + const common = [ + { id: "rowNo", title: "NO", widthPercent: 6 }, + { id: "ctpvNm", title: "시도", widthPercent: 10 }, + { id: "sggNm", title: "시군구", widthPercent: 10 }, + { id: "swtrmtZoneNm", title: "처리구역", widthPercent: 14 }, + { id: "totalLengthKm", title: "합계(km)", widthPercent: 10, align: "right" }, + ]; + + if (analysisType === "YEAR") { + return [ + ...common, + ...PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.map((column) => ({ + id: column.key, + title: column.title, + widthPercent: 12, + align: "right" as const, + })), + ]; + } + + const materialHeads = materialColumns.map((column) => ({ + id: column.key, + title: resolveMaterialColumnName(column), + widthPercent: 12, + align: "right" as const, + })); + + if (analysisType === "YEAR_MATERIAL") { + return [ + ...common, + { id: "elapsedYearLabel", title: "경과년도", widthPercent: 12 }, + ...materialHeads, + ]; + } + + return [...common, ...materialHeads]; +}; + +export const buildPipeAnalysisSummaryViewRows = ( + response: PipeAnalysisSummaryResponse, + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], +): PipeAnalysisSummaryViewRow[] => { + const materialCodes = response.materialColumns.map((column) => column.key); + + return response.data.map((row) => { + const isNoZone = String(row.swtrmtZoneNm ?? "").trim() === "NO_ZONE"; + const isSummaryTotal = !String(row.ctpvCd ?? "").trim() + && String(row.swtrmtZoneNm ?? "").trim() === "-"; + const viewRow: PipeAnalysisSummaryViewRow = { + rowNo: row.rowNo, + ctpvNm: isNoZone ? "전체" : row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2), + sggNm: isNoZone ? "-" : row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5), + swtrmtZoneNm: isNoZone ? "-" : row.swtrmtZoneNm || "-", + totalLengthKm: row.totalLengthKm ?? 0, + elapsedYearBucket: row.elapsedYearBucket ?? "", + elapsedYearLabel: row.elapsedYearLabel ?? "", + __isNoZone: isNoZone, + __isSummaryTotal: isSummaryTotal, + __cellFilters: {}, + __base: { + ctpvCd: isNoZone ? "" : row.ctpvCd, + sggCd: isNoZone ? "" : row.sggCd, + swtrmtZoneNm: isNoZone ? "NO_ZONE" : row.swtrmtZoneNm, + }, + }; + + if (isSummaryTotal) { + viewRow.ctpvNm = row.ctpvNm || viewRow.ctpvNm; + viewRow.sggNm = row.sggNm || "-"; + viewRow.swtrmtZoneNm = "-"; + viewRow.__base.ctpvCd = ""; + viewRow.__base.sggCd = ""; + viewRow.__base.swtrmtZoneNm = ""; + } + + if (response.analysisType === "YEAR") { + if (isNoZone || isSummaryTotal) { + PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => { + viewRow[column.key] = Number(row.metrics?.[column.key] ?? 0); + }); + return viewRow; + } + PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => { + const value = Number(row.metrics?.[column.key] ?? 0); + viewRow[column.key] = value; + if (value > 0) { + viewRow.__cellFilters[column.key] = { + elapsedYearBucket: column.key, + }; + } + }); + return viewRow; + } + + const columns = response.analysisType === "YEAR_MATERIAL" + ? materialCodes + : materialCodes; + + columns.forEach((columnKey) => { + const value = Number(row.metrics?.[columnKey] ?? 0); + viewRow[columnKey] = value; + if (!isNoZone && !isSummaryTotal && value > 0) { + viewRow.__cellFilters[columnKey] = { + elapsedYearBucket: response.analysisType === "YEAR_MATERIAL" ? row.elapsedYearBucket : undefined, + materialCode: columnKey === "OTHER" ? "" : columnKey, + }; + } + }); + + return viewRow; + }); +}; + +export const buildPipeAnalysisYearMaterialGroups = ( + rows: PipeAnalysisSummaryViewRow[], + materialColumns: PipeAnalysisMaterialColumn[], +): PipeAnalysisYearMaterialGroup[] => { + const materialKeys = materialColumns.map((column) => column.key); + const groupMap = new Map(); + + rows.forEach((row) => { + if (row.__isNoZone || row.__isSummaryTotal) { + return; + } + + const groupKey = `${row.__base.ctpvCd}|${row.__base.sggCd}|${row.__base.swtrmtZoneNm}`; + const elapsedYearBucket = String(row.elapsedYearBucket ?? "").trim(); + + let group = groupMap.get(groupKey); + if (!group) { + group = { + rowNo: groupMap.size + 1, + ctpvNm: String(row.ctpvNm ?? ""), + sggNm: String(row.sggNm ?? ""), + swtrmtZoneNm: String(row.swtrmtZoneNm ?? ""), + totalLengthKm: Number(row.totalLengthKm ?? 0), + base: { + ctpvCd: row.__base.ctpvCd, + sggCd: row.__base.sggCd, + swtrmtZoneNm: row.__base.swtrmtZoneNm, + }, + rows: [], + }; + groupMap.set(groupKey, group); + } + + const metrics: Record = {}; + materialKeys.forEach((materialKey) => { + metrics[materialKey] = Number(row[materialKey] ?? 0); + }); + + group.rows.push({ + elapsedYearBucket, + elapsedYearLabel: String(row.elapsedYearLabel ?? resolveElapsedYearBucketLabel(elapsedYearBucket)), + metrics, + cellFilters: row.__cellFilters, + }); + }); + + return Array.from(groupMap.values()).map((group) => { + const rowByBucket = new Map(group.rows.map((row) => [row.elapsedYearBucket, row])); + const orderedBuckets = [...YEAR_MATERIAL_BASE_BUCKETS]; + + if (rowByBucket.has("UNKNOWN")) { + orderedBuckets.push("UNKNOWN"); + } + + const normalizedRows = orderedBuckets.map((bucket) => { + const existing = rowByBucket.get(bucket); + if (existing) { + return existing; + } + + const emptyMetrics: Record = {}; + materialKeys.forEach((materialKey) => { + emptyMetrics[materialKey] = 0; + }); + + return { + elapsedYearBucket: bucket, + elapsedYearLabel: resolveElapsedYearBucketLabel(bucket), + metrics: emptyMetrics, + cellFilters: {}, + }; + }); + + return { + ...group, + rows: normalizedRows, + }; + }); +}; + +export const toPipeAnalysisDetailViewRows = ( + rows: PipeAnalysisDetailRow[], + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], +) => + rows.map((row) => ({ + ...row, + ctpvNm: row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2), + sggNm: row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5), + useNm: resolveUseName(row.useCd), + materialName: resolveMaterialName(row.materialCode), + })); + +export const buildPipeAnalysisSummaryDownloadHeaders = ( + analysisType: PipeAnalysisType, + materialColumns: PipeAnalysisMaterialColumn[], +) => { + const headers: Record = { + rowNo: "NO", + ctpvNm: "시도", + sggNm: "시군구", + swtrmtZoneNm: "처리구역", + totalLengthKm: "합계(km)", + }; + + if (analysisType === "YEAR_MATERIAL") { + headers.elapsedYearLabel = "경과년도"; + } + + if (analysisType === "YEAR") { + PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => { + headers[column.key] = column.title; + }); + return headers; + } + + materialColumns.forEach((column) => { + headers[column.key] = resolveMaterialColumnName(column); + }); + return headers; +}; + +export const PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS = { + rowNo: "NO", + ctpvNm: "시도", + sggNm: "시군구", + fcltMngNo: "시설관리번호", + instMngNo: "기관관리번호", + swtrmtZoneNm: "처리구역", + instlYmd: "설치일자", + useNm: "용도", + pdmtVl: "관경(mm)", + prlgVl: "연장(m)", + materialName: "관재질", +}; + +export const resolveMaterialName = (materialCode?: string) => { + const code = String(materialCode ?? "").trim(); + if (!code) return "-"; + if (code === "OTHER") return "기타"; + return materialMap.get(code) ?? code; +}; + +export const resolveMaterialColumnName = (column: PipeAnalysisMaterialColumn) => { + const rawName = String(column.materialName ?? "").trim(); + const rawCode = String(column.materialCode ?? "").trim(); + const rawKey = String(column.key ?? "").trim(); + if (!rawName) { + return resolveMaterialName(rawCode || rawKey); + } + if (rawName === rawCode || rawName === rawKey) { + return resolveMaterialName(rawCode || rawKey); + } + return rawName; +}; + +export const resolveElapsedYearBucketLabel = (bucket?: string) => { + const normalized = String(bucket ?? "").trim(); + return elapsedYearBucketLabelMap[normalized] ?? normalized; +}; + +export const resolvePipeAnalysisTypeLabel = (analysisType?: string) => { + const normalized = String(analysisType ?? "").trim(); + return ( + PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)?.label + ?? normalized + ); +}; + +export const resolveUseName = (useCode?: string) => { + const code = String(useCode ?? "").trim(); + if (!code) return "-"; + return useMap.get(code) ?? code; +}; + +export const resolveRegionName = ( + code: string, + options: SelectDataType[], + prefixLength: number, +) => { + const normalized = String(code ?? "").trim(); + if (!normalized) return "-"; + const prefix = normalized.slice(0, prefixLength); + return options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix; +}; + +export const normalizeNumberInput = (value: string) => value.replace(/[^0-9]/g, ""); + +export const buildPipeAnalysisFileDate = () => new Date().toISOString().slice(0, 10); + +export const fetchPipeAnalysisZoneOptions = async ({ + ctpvCd, + sggCd, + scopeRegionCode, +}: { + ctpvCd: string; + sggCd: string; + scopeRegionCode?: string; +}): Promise => { + const filters: string[] = []; + if (sggCd && sggCd !== "0") { + filters.push(`sgg_cd='${sggCd}'`); + } else if (ctpvCd && ctpvCd !== "0") { + filters.push(`ctpv_cd='${ctpvCd}'`); + } else if (scopeRegionCode) { + if (scopeRegionCode.length >= 5) { + filters.push(`sgg_cd='${scopeRegionCode.slice(0, 5)}'`); + } else if (scopeRegionCode.length >= 2) { + filters.push(`ctpv_cd='${scopeRegionCode.slice(0, 2)}'`); + } + } + + const params = new URLSearchParams({ + service: "WFS", + version: "1.0.0", + request: "GetFeature", + typeName: "hasudo:tb_ms_swage_prcs_zone", + outputFormat: "application/json", + propertyName: "ctpv_cd,sgg_cd,swtrmt_zone_nm", + }); + + if (filters.length > 0) { + params.set("CQL_FILTER", filters.join(" and ")); + } + + const response = await fetch(`/geoserver/hasudo/ows?${params.toString()}`); + if (!response.ok) { + throw new Error("처리구역 목록을 불러오지 못했습니다."); + } + + const data = await response.json(); + const features = Array.isArray(data?.features) ? data.features : []; + const dedupMap = new Map(); + + features.forEach((feature: any) => { + const properties = feature?.properties ?? {}; + const zoneName = String(properties.swtrmt_zone_nm ?? "").trim(); + if (!zoneName) return; + dedupMap.set(zoneName, { + value: zoneName, + label: zoneName, + ctpvCd: String(properties.ctpv_cd ?? "").trim(), + sggCd: String(properties.sgg_cd ?? "").trim(), + }); + }); + + return Array.from(dedupMap.values()).sort((left, right) => + left.label.localeCompare(right.label, "ko"), + ); +}; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx index 683fb9df0..6a11df513 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx @@ -13,7 +13,8 @@ * - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service) * - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service) */ -import { UiModal, UiTable } from '@common/business_components/ui'; +'use client'; +import { UiTable } from '@common/business_components/ui'; import { Button, TableHead, TableRow } from '@common/components/ui'; import React, { useState, useEffect } from 'react'; import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table'; @@ -21,21 +22,21 @@ import { VerticalTable } from '@/components/ui/verticalTable'; import { getRawJson } from "@/components/lib/api"; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import { Label } from '@radix-ui/react-label'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; const inputColumn = [ { id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] }, { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, ]; -const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { +const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, renderMode = 'inline' }: { featureData: any, isMainSsc?: boolean, chmbrXcnts?: string, chmbrYdnts?: string, - onSaveClick?: () => void + onSaveClick?: () => void, + renderMode?: 'inline' | 'popup' }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const [isDataLoaded, setIsDataLoaded] = useState(false); const [formData, setFormData] = useState({}); const [rainMeasureData, setRainMeasureData] = useState({}); // 강우, 측정 정보 const [mntrngsData, setMntrngsData] = useState([]); // 수량/수질측정 정보 @@ -151,14 +152,15 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { } }, [chmbrXcnts, chmbrYdnts]); - const openModal = () => { - setIsModalOpen(true); - }; + const openDetailPopup = () => { + const popupWindow = openWindowPopup("sscDetail", { + chmbrXcnts, + chmbrYdnts, + sourcePath: window.location.pathname, + }); - const closeModal = () => { - setIsModalOpen(false); - if (!isDataLoaded) { - setTimeout(() => setIsDataLoaded(true), 100); + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); } }; @@ -292,33 +294,24 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { /> )} - {isMainSsc && ( + {isMainSsc && renderMode !== 'popup' && (

중점우수토실 운영정보

+
+ )} - + {isMainSsc && renderMode === 'popup' && ( +
+

중점우수토실 운영정보

+ {renderModalBody()}
)} diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx index 8b029673f..78e082414 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx @@ -9,11 +9,10 @@ * - 조회 버튼을 통한 모달 표시 및 ReportTab 연동 */ 'use client' -import React, { useState } from 'react' +import React from 'react' import { VerticalTable } from '@/components/ui/verticalTable'; -import { UiModal } from '@common/business_components/ui'; import { Button } from '@common/components/ui'; -import ReportTab from '../../searchModal/reportTab'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; // 하수처리시설 const headList = [ @@ -21,16 +20,26 @@ const headList = [ ]; const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const ctpvNm = gisObject?.ctpv_nm; const sggNm = gisObject?.sgg_nm; + const handleOpenReportPopup = () => { + const popupWindow = openWindowPopup("reportTab", { + ctpvNm, + sggNm, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } + }; + return (
setIsModalOpen(true)}>조회 + title: }} headName='디지털 자료관 연계정보' tableData={[]} @@ -43,31 +52,8 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { throw new Error("Function not implemented."); }} /> - - } - useDirectRender={true} - />
) } -export default SwtrmtFclt \ No newline at end of file +export default SwtrmtFclt diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx new file mode 100644 index 000000000..b02977a09 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx @@ -0,0 +1,161 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { Table, TableBody, TableCell, TableRow } from "@common/components"; +import { fetchSwtrmtPrcsZoneAnalysis } from "./swtrmtPrcsZoneAnalysisApi"; +import type { SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse } from "./swtrmtPrcsZoneAnalysisApi"; + +const analysisRows: { key: keyof NonNullable; title: string }[] = [ + { key: "total", title: "전체" }, + { key: "combined", title: "합류관" }, + { key: "rain", title: "우수관" }, + { key: "sewage", title: "오수관" }, +]; + +const formatValue = (value: unknown, unit = "") => { + if (value === null || value === undefined || value === "") return "데이터 없음"; + + const numberValue = typeof value === "number" ? value : Number(String(value).replaceAll(",", "")); + if (!Number.isNaN(numberValue) && Number.isFinite(numberValue)) { + const formatted = numberValue.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + return unit ? `${formatted}${unit}` : formatted; + } + + return String(value); +}; + +const getMetric = ( + analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined, + key: keyof NonNullable +): SwtrmtPrcsZoneAnalysisMetric => analysis?.[key] ?? {}; + +const metricGroups: { + title: string; + field: keyof SwtrmtPrcsZoneAnalysisMetric; +}[] = [ + { title: "평균매설년도(년)", field: "avgInstlYear" }, + { title: "최장매설년도(년)", field: "oldestInstlYear" }, + { title: "최신매설년도(년)", field: "latestInstlYear" }, +]; + +const MetricVerticalGrid = ({ + title, + field, + analysis, + isLoading, +}: { + title: string; + field: keyof SwtrmtPrcsZoneAnalysisMetric; + analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined; + isLoading?: boolean; +}) => ( +
+
{title}
+ + + {analysisRows.map((row) => { + const metric = getMetric(analysis, row.key); + return ( + + + {row.title} + + {isLoading ? "" : formatValue(metric[field])} + + ); + })} + +
+
+); + +const SwtrmtPrcsZoneAnalysis = ({ + featureData, + rawFeatureData, +}: { + featureData: any; + rawFeatureData?: any; +}) => { + const [analysisData, setAnalysisData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const requestKey = useMemo(() => { + const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {}; + return [ + rawFeatureData?.getId?.(), + rawFeatureData?.id_, + rawFeatureData?.fid, + rawValues?.fid, + rawValues?.swtrmt_zone_sn, + rawValues?.swtrmt_fclt_cd, + featureData?.fid, + featureData?.swtrmtZoneSn, + featureData?.swtrmt_zone_sn, + featureData?.swtrmtFcltCd, + featureData?.swtrmt_fclt_cd, + ].filter(Boolean).join("|"); + }, [featureData, rawFeatureData]); + + useEffect(() => { + let isMounted = true; + + const fetchAnalysis = async () => { + setIsLoading(true); + setErrorMessage(""); + setAnalysisData(null); + + try { + const data = await fetchSwtrmtPrcsZoneAnalysis(rawFeatureData, featureData); + if (!isMounted) return; + + if (!data) { + setErrorMessage("분석정보 조회에 필요한 처리구역 식별값이 없습니다."); + return; + } + + setAnalysisData(data); + } catch (error) { + if (!isMounted) return; + console.error("하수처리구역 분석정보 로딩 중 오류:", error); + setErrorMessage("분석정보를 불러오지 못했습니다."); + } finally { + if (isMounted) setIsLoading(false); + } + }; + + fetchAnalysis(); + + return () => { + isMounted = false; + }; + }, [featureData, rawFeatureData, requestKey]); + + return ( + <> +
+

처리구역내 관로분석정보

+
+
+
+ {!isLoading && errorMessage && ( +
{errorMessage}
+ )} + {(isLoading || !errorMessage) && ( + metricGroups.map((group) => ( + + )) + )} +
+
+ + ); +}; + +export default SwtrmtPrcsZoneAnalysis; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx index 3836b87f6..91495f412 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx @@ -7,14 +7,12 @@ import { CHART_FORM, type ChartConfig, MainChart, - UiModal, } from '@common/business_components'; import { SelectBox } from '@common/business_components/ui'; import { Button, Input, Label } from '@common/components'; import type { TInftwtAnls } from './aplcnYms'; import * as fn from './aplcnYms'; -import { useInfilChartModalStore } from '../chartModalStore'; type ModalOpenProp = { pplMngNo?: string; // 관로관리번호 @@ -28,7 +26,6 @@ type DataType = { const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { - const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useInfilChartModalStore(); const [selectedAplcnYm, setSelectedAplcnYm] = useState(''); const [inftwtAnlss, setInftwtAnlss] = useState([]); @@ -66,12 +63,6 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { setInftwtQty('0'); }; - useEffect(() => { - if (!isModalOpen) { - resetState(); // 모달이 닫힐 때 리셋 - } - }, [isModalOpen]); - const handleAplcnYmChange = async (val: string | number) => { const selectedValue = String(val); @@ -276,21 +267,10 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => { ); return ( - +
+

침입수 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx index aa622be1b..ec1cec8b1 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx @@ -7,14 +7,12 @@ import { CHART_FORM, type ChartConfig, MainChart, - UiModal, } from '@common/business_components'; import { SelectBox } from '@common/business_components/ui'; import { Button, Input, Label } from '@common/components'; import type { TRdiiAnls } from './aplcnYms'; import * as fn from './aplcnYms'; -import { useRdiiChartModalStore } from '../chartModalStore'; type ModalOpenProp = { pplMngNo?: string; // 관로관리번호 @@ -29,7 +27,6 @@ type LineBarChartData = { const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { - const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useRdiiChartModalStore(); const [aplcnYm, setAplcnYm] = useState(''); const [rdiiAnlss, setRdiiAnlss] = useState([]); @@ -72,12 +69,6 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { setTotalVolume('0');// 총체적 m³/mm 초기화 }; - useEffect(() => { - if (!isModalOpen) { - resetState(); - } - }, [isModalOpen]); - const handleAplcnYmChange = async (val: string | number) => { const selectedValue = String(val); @@ -336,21 +327,10 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { ); return ( - +
+

RDII 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts deleted file mode 100644 index c84fa48a7..000000000 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand'; - -interface ModalStore { - isOpen: boolean; - setIsOpen: (open: boolean) => void; -} - -export const useInfilChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); - -export const useRdiiChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 11e61b006..36cbb46f6 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -23,7 +23,6 @@ */ 'use client' import { getData, postData } from "@/components/lib/api"; -import Modal from "@/components/ui/modal"; import { VerticalTable } from "@/components/ui/verticalTable"; import { useGisContext } from "@common/business_components/ui/map/Gis"; import { Button } from "@common/components/ui"; @@ -34,13 +33,12 @@ import Stroke from "ol/style/Stroke.js"; import CircleStyle from "ol/style/Circle.js"; import { getLayerContentComponent } from "../common"; import { SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table"; -import InfilChart from './chart/infilChart'; -import RdiiChart from './chart/rdiiChart'; -import { useInfilChartModalStore, useRdiiChartModalStore } from "./chartModalStore"; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import { codeMapper } from "../common/codeMapper"; import LoadingOverlay from '@/components/ui/LoadingBar'; import { searchUpperPpls } from "./smh"; +import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils"; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; const linkedFacilityHighlightStyle = new Style({ image: new CircleStyle({ @@ -63,7 +61,6 @@ const linkedFacilityHighlightStyle = new Style({ zIndex: 9999, }); - const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const [layerNm, setLayerNm] = useState(""); // 레이어 이름 상태 @@ -74,12 +71,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [buttonChk, setButtonChk] = useState(true) const [fetchData, setFetchData] = useState(); // api 호출여부 const contentRef = useRef(null); - const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태 const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부 const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부 // 🎯 InfilChart 및 RdiiChart 관련 상태 추가 - const { setIsOpen: setIsModalInfil } = useInfilChartModalStore(); - const { setIsOpen: setIsModalRdii } = useRdiiChartModalStore(); const [isClicked, setIsClicked] = useState(true); // 클릭 여부 상태 const [isLoading, setIsLoading] = useState(false); // 로딩 상태 const [chmbrXcnts, setChmbrXcnts] = useState(''); // 로딩 상태 @@ -88,7 +82,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const linkedFacilityOriginalStyleRef = useRef(null); // 🎯 관로관리번호 추출 - const pplMngNo = featureData?.values_?.fclt_mng_no || ""; + const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || ""; const featureId = typeof featureData.getId === 'function' ? featureData.getId() @@ -258,7 +252,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, case 'tb_ms_swage_ppl': { result = '하수관로'; setHeadlist(MsSwagePplCol2) - setLayerSpecificNm(featureData.swtrmtFcltCd); + setLayerSpecificNm(featureData.fcltMngNo ?? featureData.swtrmtFcltCd); break; } case 'tb_ms_swage_mhl': { @@ -341,6 +335,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 🎯 기본 데이터 포맷팅 const formattedData = formatDisplayData(data); + if (selectedLayerName === '하수관로') { + const installYmd = data.instl_ymd ?? data.instlYmd; + formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd); + if (isInvalidInstallYmd(installYmd)) { + formattedData.instl_ymd = "확인불가"; + formattedData.instlYmd = "확인불가"; + } + } setFetchData(formattedData); setTableData(formattedData); @@ -407,8 +409,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 자체입력정보 API 엔드포인트 선택 let apiEndpoint = ''; - const fcltMngNo = featureData?.values_?.fclt_mng_no || null; - const mngInstCd = featureData?.values_?.mng_inst_cd || null; + const fcltMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || null; + const mngInstCd = featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || null; if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`; @@ -461,16 +463,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 저장 시 모달 띄우기 const handleSaveClick = () => { - setIsModalOpen(true); // 모달 열기 - }; - - const handleModalSave = () => { - setIsModalOpen(false); // 모달 닫기 - btnClickEvent(); // 저장 이벤트 실행 - }; - - const handleModalClose = () => { - setIsModalOpen(false); // 모달 닫기 + const shouldSave = window.confirm('정보를 저장하시겠습니까?'); + if (!shouldSave) return; + btnClickEvent(); }; // 저장 버튼 이벤트 @@ -557,14 +552,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, {/* 하수관로일 때만 Infil/RDII 버튼 표시 */} {layerNm === '하수관로' && ( <> - - + + + )} ); diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportFilesModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportFilesModal.tsx deleted file mode 100644 index 2b7218332..000000000 --- a/apps/sn/app/(map-layout)/map/searchModal/reportFilesModal.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭) - * - * 📄 Report Files Modal - * Next.js App Router (app/) 기반 보고서 파일 모달 컴포넌트 - * - * - 하수도정비 기본계획 보고서 파일 목록 표시 - * - 파일명 및 등록일자 정보 테이블 출력 - * - 페이지네이션 및 로딩 오버레이 지원 - * - 파일 클릭 이벤트 처리 및 상세 조회 - * - Zustand를 통한 모달 상태 및 데이터 관리 - * - 모달 열림/닫힘 시 데이터 초기화 기능 - */ -'use client' -import React, { useEffect } from 'react'; -import LoadingOverlay from '@/components/ui/LoadingBar'; -import useReportTab from './useReportTab'; -import { useReportTabStore } from '@/store/useReportTabStore'; -import { UiTable } from '@common/business_components/ui'; -import { UiModal } from "@common/business_components"; -import useSearchModalStore from '@/store/useSearchModalStore'; - -type ModalOpenProp = { clsfSystmSn: string } // 자료관 기본계획 일련번호 -const ReportFilesModal: React.FC = ({ clsfSystmSn }: ModalOpenProp) => { - // 하수도정비 기본계획 보고서 파일목록 - const REPORT_FILES_HEADLIST = [ - { id: "no", title: "NO" }, - { id: "fileTitle", title: "파일명" }, - { id: "regDt", title: "등록일자" }, - ]; - - const { - curPage, setCurPage, - isLoading, - isResetPage, - isResetSelection, - setIsResetPage, - setIsResetSelection - } = useSearchModalStore(); - const { - isOpen: isModalOpen, - files, - dcFiles: filesOrigin, - setIsOpen: setIsModalOpen, - setFiles, - fetchDcFiles - } = useReportTabStore(); - const { getFiles, handleCellClick } = useReportTab(); - - - useEffect(() => { - fetchDcFiles(clsfSystmSn); - }, [clsfSystmSn]); - - useEffect(() => { - if (isModalOpen === true) { - getFiles(); // 파일 목록 조회 - } else { - setFiles([]); // 모달이 닫힐 때 파일 목록 초기화 - setCurPage(1); // 페이지를 1로 초기화 - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(true); // 선택 초기화 - } - }, [filesOrigin, isModalOpen, curPage]); - - const renderBody = () => ( - <> -
- - { - return (<>조회 결과 ({filesOrigin.length.toLocaleString('ko-KR')})건); - })() - } - tableData={files} - headlist={REPORT_FILES_HEADLIST} - pageSize={10} - total={filesOrigin.length} - cellClick={handleCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - /> -
- - ); - - - return ( - - ); -}; - -export default React.memo(ReportFilesModal); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx index ad57c0a5f..f054a9ed1 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx @@ -22,7 +22,7 @@ import useStore from '@/store/useStore'; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import useSearchModalStore from '@/store/useSearchModalStore'; import { useReportTabStore } from '@/store/useReportTabStore'; -import ReportFilesModal from './reportFilesModal'; // 파일목록모달 +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; // 하수도정비 기본계획 보고서 @@ -56,7 +56,6 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg const [ctpvCd, setCtpvCd] = useState('0'); const [sggCd, setSggCd] = useState('0'); const [reportGubun, setReportGubun] = useState("0"); - const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호 const { tableData, @@ -72,8 +71,6 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg } = useSearchModalStore(); const { ctpv, sgg } = useStore(); const { - isOpen: isReportFilesModalOpen, - setIsOpen: setIsReportFilesModalOpen, dcTree: reportOriginData, fetchDcTree } = useReportTabStore(); // 모달 상태 관리 스토어 @@ -139,8 +136,14 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg if (typeof index === "number" && tableData[index]) { const reportData = tableData[index]; if (reportData.fileSn !== "") { - setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정 - setIsReportFilesModalOpen(true); // 모달 열기 + const popupWindow = openWindowPopup("reportFiles", { + clsfSystmSn: reportData.clsfSystmSn, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } } }; @@ -263,10 +266,8 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg /> - {/* 모달 컴포넌트 */} - {isReportFilesModalOpen && } ); }; -export default React.memo(ReportTab); \ No newline at end of file +export default React.memo(ReportTab); diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 3ce334518..809647bf6 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -11,21 +11,26 @@ * - Zustand를 통한 검색 모달 상태 관리 * - 로딩 오버레이 및 선택 초기화 지원 */ +'use client'; import LoadingOverlay from '@/components/ui/LoadingBar'; -import { UiModal, UITab, UiTable } from '@common/business_components/ui'; +import { UITab, UiTable } from '@common/business_components/ui'; import { Button } from '@common/components/ui'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { Toaster } from "react-hot-toast"; import useSearchModalStore from '@/store/useSearchModalStore'; import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table'; import BasicTab, { BASIC_TYPES } from './basicTab'; import OperationTab from './operationTab'; -import ReportTab from './reportTab'; import useSearchModal from './useSearchModal'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; -const SearchModal: React.FC = () => { +type SearchModalProps = { + renderMode?: "launcher" | "popup"; +}; + +const SearchModal: React.FC = ({ renderMode = "launcher" }) => { const { showTable, isSearchModalOpen, setIsSearchModalOpen, @@ -59,6 +64,7 @@ const SearchModal: React.FC = () => { operationResultExcelDownload, handleOperationPageNation } = useOperationTab(); + const hasAutoOpenedReportRef = useRef(false); const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => { @@ -87,6 +93,42 @@ const SearchModal: React.FC = () => { return []; }, [activeTab, workType]); + useEffect(() => { + if (renderMode !== "launcher" || !isSearchModalOpen) return; + + const popupWindow = openWindowPopup("mapSearch", { + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } + + setIsSearchModalOpen(false); + }, [renderMode, isSearchModalOpen]); + + const openReportTabPopup = () => { + const popupWindow = openWindowPopup("reportTab", { + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.'); + } + }; + + useEffect(() => { + if (activeTab !== 'report') { + hasAutoOpenedReportRef.current = false; + return; + } + + if (hasAutoOpenedReportRef.current) return; + + openReportTabPopup(); + hasAutoOpenedReportRef.current = true; + }, [activeTab]); + useEffect(() => { resetFilters(); }, [activeTab, basicType, workType]); @@ -119,7 +161,23 @@ const SearchModal: React.FC = () => { /> {activeTab === 'basic' && ()} {/* 기본정보 탭 */} {activeTab === 'operation' && ()} {/* 유지관리 정보 탭 */} - {activeTab === 'report' && ()} {/* 하수도정비기본계획보고서 탭 */} + {activeTab === 'report' && ( +
+
+

+ 하수도정비 기본계획보고서 +

+

+ 보고서 상세는 모달 내부 탭 대신 +
+ 전용 윈도우 팝업으로 열리도록 전환되었습니다. +

+ +
+
+ )} {/* 하수도정비기본계획보고서 탭 */} {/* 📋 결과 리스트 */} @@ -214,22 +272,16 @@ const SearchModal: React.FC = () => { ); + if (renderMode === "launcher") { + return null; + } + return ( -
- +
+

관망도 검색

+
+ {renderBody()} +
); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 9184286ed..d914d2dbb 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -31,6 +31,13 @@ import { swagePrcsZoneCqlFilters } from './swagePrcsZone'; import { swageMhlCqlFilters } from './swageMhl'; import { swagePmstnCqlFilters } from './swagePmstn'; import { stofcbrCqlFilters } from './stofcbr'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useBasicTab() { @@ -115,9 +122,21 @@ export default function useBasicTab() { // 기본정보 테이블 cellClick 핸들러 const handleBasicCellClick = async (index?: number | undefined, headId?: String) => { if (typeof index === "number") { + const row = tableData[index]; + + if (isPopupWindow()) { + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE, + { + layerNm: row.lyrTypeNm, + fid: String(row.fid), + }, + ); + return; + } + setIsLoading(true); - const row = tableData[index]; const layerNm = row.lyrTypeNm; const fid = row.fid; const core = GisApp.Module.layer as any; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index 669dd4cb5..3d80b54d9 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -23,6 +23,13 @@ import { confirmToast } from '@/components/ui/toast' import { toast } from "react-hot-toast"; import useSearchModalStore from '@/store/useSearchModalStore'; import useStore from '@/store/useStore'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useOperationTab() { @@ -200,9 +207,21 @@ export default function useOperationTab() { // 유지관리 정보 디테일 테이블 cellClick 핸들러 const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => { if (typeof index === "number") { + const row = detailTableData[index]; + + if (isPopupWindow()) { + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE, + { + layerNm: row.lyrTypeNm, + fid: String(row.fid), + }, + ); + return; + } + setIsLoading(true); - const row = detailTableData[index]; const layerNm = row.lyrTypeNm; const fid = row.fid; const core = GisApp.Module.layer as any; @@ -434,6 +453,20 @@ export default function useOperationTab() { } }; + const emitMultipleFeaturesToMap = () => { + if (detailTableData.length === 0 || !isPopupWindow()) return false; + + const row = detailTableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(detailTableData.map((item) => item.fid)), + }, + ); + return true; + }; + return { WORK_TYPES, @@ -446,7 +479,8 @@ export default function useOperationTab() { handleOperationCellClick, handleOperationResultCellClick, handleOperationPageNation, + emitMultipleFeaturesToMap, operationExcelDownload, operationResultExcelDownload }; -}; \ No newline at end of file +}; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 1d317e3d4..585885521 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -19,6 +19,13 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core"; import useSearchModalStore from '../../../store/useSearchModalStore'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; +import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; + +const isPopupWindow = () => + typeof window !== "undefined" && + !!window.opener && + window.location.pathname.startsWith("/popup/"); export default function useSearchModal() { const { @@ -132,6 +139,18 @@ export default function useSearchModal() { const viewMultipleFeature = async () => { if (activeTab === 'basic') { if (!(tableData.length === 0)) { + if (isPopupWindow()) { + const row = tableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(tableData.map((item) => item.fid)), + }, + ); + return; + } + setIsLoading(true); const row = tableData[0]; @@ -144,6 +163,18 @@ export default function useSearchModal() { } } else if (activeTab === 'operation') { if (!(detailTableData.length === 0)) { + if (isPopupWindow()) { + const row = detailTableData[0]; + postPopupMessageAndClose( + WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE, + { + layerNm: row.lyrTypeNm, + fids: JSON.stringify(detailTableData.map((item) => item.fid)), + }, + ); + return; + } + setIsLoading(true); const row = detailTableData[0]; diff --git a/apps/sn/app/(popupLayout)/layout.tsx b/apps/sn/app/(popupLayout)/layout.tsx new file mode 100644 index 000000000..d02ca99ac --- /dev/null +++ b/apps/sn/app/(popupLayout)/layout.tsx @@ -0,0 +1,11 @@ +export default function PopupLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx new file mode 100644 index 000000000..8231f4f4d --- /dev/null +++ b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx @@ -0,0 +1,30 @@ +import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; +import { windowPopupRegistry } from "@/features/windowPopup/registry"; + +type PopupPageProps = { + params: Promise<{ + popupKey: string; + }>; + searchParams: Promise>; +}; + +export default async function PopupPage({ + params, + searchParams, +}: PopupPageProps) { + const { popupKey } = await params; + const resolvedSearchParams = await searchParams; + + if (!isWindowPopupKey(popupKey)) { + return ( +
+

등록되지 않은 팝업입니다.

+

popupKey: {popupKey}

+
+ ); + } + + const PopupComponent = windowPopupRegistry[popupKey].component; + + return ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 4b1db105b..3df6a1541 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -17,22 +17,20 @@ * - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service) */ 'use client' -import { deleteData, postData, useCookieAuth } from '@/components/lib/api' +import { deleteData, useCookieAuth } from '@/components/lib/api' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' -import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' import { Button, DatePicker, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlist } from '..' -import OperFileUploadForm from '../../../components/ui/operFileUploadForm' -import { tokenManager } from '@/entities/auth/lib/token-manager'; -import { AUTH_USER_ID } from "@/shared/config"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; -import TableModal from './tableModal'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; const Page = () => { @@ -51,8 +49,6 @@ const Page = () => { const [curPage, setCurPage] = useState(1) //현재 페이지 const [totalData, setTotalData] = useState(0) //총 데이터 수 const [isLoading, setIsLoading] = useState(true) //로딩 상태 - const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) // 등록 모달 상태 - const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태 const [resetSelection, setResetSelection] = useState(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(false); @@ -67,6 +63,17 @@ const Page = () => { fetchData() }, []) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED) return; + handleSearch(); + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [fromDate, toDate, pjNm, curPage]); + // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetPage(false); @@ -140,27 +147,24 @@ const Page = () => { } } - // 현황 등록 - const registFile = async (fileName: string, year: string, quater: string, cd: string, id: string) => { - const json = { - mngYr: year, - mngQu: quater, - atflId: id, - atflCd: cd, - rgtr: tokenManager.getToken(AUTH_USER_ID) || null, - sbmrgPrevntBizNm: fileName - } - const data = await postData('v1/sbmrgPrevnt', json) - if (data) { - alert('파일이 등록되었습니다.'); - setIsUploadModalOpen(false); - handleSearch(); + // 등록 버튼 클릭 핸들러 + const handleRegisterClick = () => { + const popupWindow = openWindowPopup("floodPreUpload"); + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); } } - // 등록 버튼 클릭 핸들러 - const handleRegisterClick = () => { - setIsUploadModalOpen(true); + const handleTablePopupOpen = (fileName: string, id: string) => { + const popupWindow = openWindowPopup("floodPreTable", { + fileNm: fileName, + atflId: id, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } @@ -246,7 +250,7 @@ const Page = () => { setFileNm(fileName); setAtflId(id); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn); - setIsTableModalOpen(true); + handleTablePopupOpen(fileName, id); } }} onPageChange={setCurPage} @@ -254,40 +258,8 @@ const Page = () => { resetPageNo={isResetPage} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx index f4acc2056..4b35069af 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx @@ -55,6 +55,12 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { return false } + if (atflId === '' || atflId === undefined) { + alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.'); + setIsLoading(false); + return false; + } + const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`); const file = new File([blob], fileNm, { @@ -110,6 +116,8 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { const filteredResultData = filterEmptyRows(resultData); setResultTableData(filteredResultData); } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('FloodPre attachment fetch details:', { atflId, fileNm, message }); console.error('파일 처리 중 오류 발생:', error); alert('파일 처리 중 오류가 발생했습니다.'); } finally { diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index a078a9795..86d5d4853 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -17,22 +17,20 @@ * - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service) */ 'use client' -import { deleteData, postData, useCookieAuth } from '@/components/lib/api' +import { deleteData, useCookieAuth } from '@/components/lib/api' import { searchData } from '@/components/lib/common/apiHelpers' import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' -import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui' +import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' import { Button, DatePicker, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlistOldPp } from '..' -import OperFileUploadForm from '../../../components/ui/operFileUploadForm' -import { tokenManager } from '@/entities/auth/lib/token-manager'; -import { AUTH_USER_ID } from "@/shared/config"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; -import TableModal from './tableModal'; +import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; +import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; const Page = () => { @@ -49,8 +47,6 @@ const Page = () => { const [curPage, setCurPage] = useState(1) const [totalData, setTotalData] = useState(0) //총 데이터 수 const [isLoading, setIsLoading] = useState(true) //로딩 상태 - const [isModalOpen, setIsModalOpen] = useState(false) // 등록 모달 상태 - const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태 const [resetSelection, setResetSelection] = useState(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(false); @@ -65,6 +61,17 @@ const Page = () => { fetchData() }, []) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED) return; + handleSearch(); + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [fromDate, toDate, pjNm, curPage]); + // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetPage(false); @@ -137,27 +144,25 @@ const Page = () => { } } - // 파일 등록 - const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => { - const json = { - mngYr: year, - mngQu: quater, - atflCd: cd, - atflId: id, - rgtr: tokenManager.getToken(AUTH_USER_ID) || null, - dcrpPplMtncBizNm: fileNm - } - const data = await postData('v1/dcrpPpl', json); - if (data) { - alert('파일이 등록되었습니다.'); - setIsModalOpen(false); - handleSearch(); + // 등록 버튼 클릭 핸들러 + const handleRegisterClick = () => { + const popupWindow = openWindowPopup("oldPpUpload"); + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); } } - // 등록 버튼 클릭 핸들러 - const handleRegisterClick = () => { - setIsModalOpen(true); + // 엑셀 데이터는 브라우저 윈도우 팝업에서 독립 조회 + const handleTablePopupOpen = (fileName: string, id: string) => { + const popupWindow = openWindowPopup("oldPpTable", { + fileNm: fileName, + atflId: id, + sourcePath: window.location.pathname, + }); + + if (!popupWindow) { + alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + } } @@ -242,47 +247,15 @@ const Page = () => { setFileNm(fileName); setAtflId(id); setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn); - setIsTableModalOpen(true); + handleTablePopupOpen(fileName, id); } }} onPageChange={setCurPage} resetSelection={resetSelection} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx index 13921b2c1..f22df820c 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx @@ -64,6 +64,12 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { return false; } + if (atflId === '' || atflId === undefined) { + alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.'); + setIsLoading(false); + return false; + } + const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`); const file = new File([blob], fileNm, { @@ -242,6 +248,8 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { })); setTableData6(filteredData6); } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('OldPp attachment fetch details:', { atflId, fileNm, message }); console.error('파일 처리 중 오류 발생:', error); alert('파일 처리 중 오류가 발생했습니다.'); } finally { diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 73025226b..08d2dfbe8 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -143,17 +143,22 @@ export const deleteData = async (url: string, data?: object): Promise => { return res; }; -export const getRawJson = async (url: string): Promise => { +// GET 응답을 공통 data wrapper 없이 원문 JSON으로 읽는다. init은 지도 bbox 요청처럼 AbortSignal이 필요한 호출에서 사용한다. +export const getRawJson = async (url: string, init: RequestInit = {}): Promise => { const res = await fetch(resolveApiUrl(url), { + ...init, method: "GET", - headers: withSiteIdHeaders(), + headers: withSiteIdHeaders(init.headers), }); if (res.status === 401) { handleUnauthorizedResponse(); } if (!res.ok) { - throw new Error('Failed to fetch data'); + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch data: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""}`, + ); } const contentLength = res.headers.get('content-length'); @@ -170,7 +175,7 @@ export const getRawJson = async (url: string): Promise => { return JSON.parse(text); } catch (error) { console.error('JSON parsing error:', error); - throw new Error('Invalid JSON response from server'); + throw new Error(`Invalid JSON response from server: ${url}`); } }; @@ -184,7 +189,10 @@ export const getBlobData = async (url: string): Promise => { } if (!res.ok) { - throw new Error('Failed to fetch data'); + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, + ); } return res.blob(); diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx deleted file mode 100644 index dce0ee2b5..000000000 --- a/apps/sn/app/components/ui/modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 📄 Modal Component - * 모달 컴포넌트 - * - * - 모달 열기/닫기 상태 관리 - * - 제목 및 내용 표시 - * - 저장 및 닫기 버튼 지원 - * - 반응형 크기 조정 (최대/최소 너비 및 높이) - * - 오버레이 및 z-index를 통한 최상위 레이어 배치 - * - 접근성 지원 (aria-describedby) - */ -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogOverlay } from '@common/components/ui' -import React from 'react' - -interface ModalProps { - isModalOpen: boolean; - setIsModalOpen: (open: boolean) => void; - title?: string; - children: React.ReactNode; - closeModal: () => void; - saveModal?: () => void; - description?: string; -} - -const Modal: React.FC = ({ isModalOpen, setIsModalOpen, title, children, closeModal, saveModal }) => { - return ( - - - - {title ? - {title} - : null} - {children} - -
- {saveModal ? : null} - -
-
-
-
- ) -} - -export default Modal \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx index cf6db8572..88b5ddbe8 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@common/components"; +import { VerticalTable } from "@/components/ui/verticalTable"; import { AGED_PIPE_DETAIL_FIELDS, getElapsedYears, @@ -16,39 +17,48 @@ export default function AgedPipeDetailPanel({ featureData, onClose }: Props) { const source = featureData?.values_ ?? featureData ?? {}; const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-"); const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd); + const detailFields = [ + ...AGED_PIPE_DETAIL_FIELDS, + { id: "elapsedYears", title: "경과년도" }, + ]; + const tableData = detailFields.reduce>((acc, field) => { + acc[field.id] = + field.id === "elapsedYears" + ? elapsedYears === null + ? "확인불가" + : `${elapsedYears}년` + : String(getPipeDisplayValue(source, field.id) ?? "-"); + return acc; + }, {}); return ( -
-
+
+
-
노후관로 상세
-
시설관리번호 {facilityNo}
-
- 경과년도 {elapsedYears ?? "-"}년 +
+ 하수관로 정보
관리번호 : {facilityNo}
-
-
- {AGED_PIPE_DETAIL_FIELDS.map((field) => ( -
-
{field.title}
-
- {String(getPipeDisplayValue(source, field.id) ?? "-")} -
-
- ))} -
+
+ undefined} + />
); diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx index 5a94014a2..a80ced9c9 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx @@ -1,7 +1,6 @@ "use client"; -import { Button, Input } from "@common/components"; -import useStore from "@/store/useStore"; +import { useState } from "react"; import type { AgedPipeFilters } from "./agedPipeUtils"; type OverlayProps = { @@ -13,89 +12,62 @@ type OverlayProps = { }; type Props = OverlayProps & { + title?: string; filters: AgedPipeFilters; onFiltersChange: (filters: AgedPipeFilters) => void; onApply: () => void; }; export default function AgedPipeFilterPanel({ + title = "지반침하정보", filters, onFiltersChange, onApply, }: Props) { - const { ctpv, sgg } = useStore(); - const sggOptions = filters.ctpvCd === "0" - ? sgg - : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + const [isCollapsed, setIsCollapsed] = useState(false); return ( -
-
-
노후관로 기준
-
- 경과년수 이상 관로만 지도와 목록에 반영합니다. +
+
+ + {title} +
+ + {!isCollapsed && ( +
+
+ + { + const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0"); + onFiltersChange({ ...filters, elapsedYears: next }); + }} + onKeyDown={(event) => { + if (event.key === "Enter") onApply(); + }} + /> + +
-
- -
- - - - - -
- - + )}
); } diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx index bf6a8fb02..a5c90e184 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx @@ -64,14 +64,11 @@ export default function AgedPipeListModal({ return (
-
+
-
+
노후관로 목록
-
- 지도 위에 띄워지는 윈도우형 팝업 미리보기 -
+
+
HASUON
+
디지털하수관로 시스템
+
+
+ +
+ + +
+ + +
+ + +
+
+
+
+
+
+ + 하수처리시설 + 왕숙천 + 진건로 + +
+
+
+
+ + + + + + + + +
+ + + + + + + +
+
+
+ +
+ +
+ +
+ + + + diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx index 9363fe185..b8b4897c8 100644 --- a/apps/sn/app/layout.tsx +++ b/apps/sn/app/layout.tsx @@ -12,7 +12,11 @@ * - 앱 전체의 공통 스타일 및 스크립트 적용 */ import type { Metadata } from "next"; -import { App } from "@/app"; +import "@fontsource-variable/noto-sans-kr"; +import { cookies } from "next/headers"; +import { AppProvider } from "./providers"; +import "../src/app/styles/base.css"; +import "../src/app/styles/components.css"; export const metadata: Metadata = { title: "디지털 하수관로", @@ -24,4 +28,16 @@ export const metadata: Metadata = { } }; -export default App; \ No newline at end of file +export default async function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const theme = (await cookies()).get("site-theme-key"); + + return ( + + + {children} + + + ); +} diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index 2a1db71d8..b02c4bdca 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -6,7 +6,7 @@ * * - 쿠키 기반 인증 토큰 검증 및 자동 로그인 처리 * - 로그인 성공 시 지도 페이지로 리다이렉트 - * - 기존 토큰 존재 시 바로 지도 페이지 이동 + * - 개발 환경에서는 루트 진입 시 자동 로그인 후 지도 페이지 이동 * - 로그인 실패 시 에러 로깅 및 처리 * - 사용자 세션 관리 및 초기 라우팅 제어 * - Next.js useRouter를 통한 클라이언트 사이드 라우팅 @@ -17,28 +17,34 @@ "use client"; import { LoginApi } from "@/entities/auth"; import { useEffect } from "react"; -import { getCookie } from "@/shared/lib"; -import { AUTH_USER_ID } from "@/shared/config"; + +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() { - const checkToken = getCookie(AUTH_USER_ID); - useEffect(() => { - const hostname = window.location.hostname; + const isDevHost = DEV_HOSTNAMES.has(hostname); - if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") { - if (!checkToken) { - async function doLogin() { - try { - await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" }); - window.location.href = "/map"; - } catch (err) { - console.error("로그인 실패", err); - } - } - doLogin(); - } else window.location.href = "/map"; - } else window.location.href = "/map"; + if (!isDevHost) { + window.location.replace("/map"); + return; + } + + async function doLogin() { + try { + // 개발 환경에서는 남아 있는 만료/부분 쿠키로 인한 /map <-> / 루프를 막기 위해 항상 토큰을 재발급한다. + await LoginApi.login(DEV_LOGIN_PAYLOAD); + window.location.replace("/map"); + } catch (err) { + console.error("로그인 실패", err); + } + } + + void doLogin(); }, []); } diff --git a/apps/sn/app/providers.tsx b/apps/sn/app/providers.tsx new file mode 100644 index 000000000..4bbe9f864 --- /dev/null +++ b/apps/sn/app/providers.tsx @@ -0,0 +1 @@ +export { AppProvider } from "../src/app/providers"; diff --git a/apps/sn/docs/AI코딩하네스/개요.md b/apps/sn/docs/AI코딩하네스/개요.md deleted file mode 100644 index 1f26ebdd2..000000000 --- a/apps/sn/docs/AI코딩하네스/개요.md +++ /dev/null @@ -1,41 +0,0 @@ -# sn 2차사업 하네스 운영 문서 - -## 목적 -이 문서는 하수관로 운영관리 시스템 `sn` 프론트엔드 2차사업 작업을 기능 단위로 안전하게 진행하기 위한 경량 하네스 기준이다. - -AI_SAM `refactoring` 브랜치의 하네스 운영 방식 중 우리 프로젝트에 필요한 부분만 수용한다. - -- 작업카드로 범위와 검증 방법을 먼저 고정한다. -- 실행 기록을 남겨 중간에 작업자가 바뀌어도 이어서 진행할 수 있게 한다. -- `sn` 앱과 허용된 공통 비즈니스 컴포넌트 범위를 벗어나는 수정을 방지한다. -- 백엔드 API 계약과 화면 설계 근거를 연결해서 브라우저 검증까지 추적한다. - -## 적용 범위 -- 확인됨: `apps/sn/**`은 이번 작업의 주 프론트엔드 범위이다. 근거: `apps/sn/AGENTS.md` -- 확인됨: `packages/common/business_components/**`만 수정 가능한 공통 비즈니스 컴포넌트 범위이다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 그 외 앱과 공통 패키지는 기본적으로 읽기/분석 대상이며 직접 수정하지 않는다. 근거: `apps/sn/AGENTS.md` -- 추정: 기능 구현 시 백엔드 `sewerage-service` API와 함께 브라우저 동작을 확인해야 한다. 근거: `apps/sn/AGENTS.md` - -## 문서 구조 -- `작업목록.md`: 2차사업 프론트엔드 작업 목록과 상태 -- `기준문서/`: 프로젝트, 화면, API, 컴포넌트, 상태관리, 검증 기준 -- `작업계획/`: 작업카드 템플릿과 기능별 실행 계획 -- `보고/`: 일자별 작업 결과, 이슈, 제안, 개선 기록 - -## 멀티에이전트 기준 -- `sn` 작업에서 BE/FE/QA 역할 분리와 합류 기준은 `기준문서/007-멀티에이전트운영.md`를 따른다. -- 백엔드 API, DB, 서비스 흐름과 함께 작업하는 경우 백엔드 기준문서 `D:\workspace\IIPBS\sewerage-service\docs\AI코딩하네스\기준문서\009-멀티에이전트운영.md`도 함께 확인한다. - -## 운영 원칙 -- 기능 단위로 작업카드를 먼저 작성한다. -- 수정 전 영향 범위와 롤백 방법을 문서에 남긴다. -- API 계약 변경이 필요한 경우 프론트에서 임의로 처리하지 않고 백엔드 작업으로 분리한다. -- `packages/common/business_components` 수정은 `sn` 화면에서 실제로 재사용되는 범위로 제한한다. -- 브라우저 테스트가 필요한 작업은 URL, 사용자 조작, 기대 결과를 함께 기록한다. - -## 사용 순서 -1. `기준문서/` 문서에서 현재 기준을 확인한다. -2. `작업목록.md`에서 작업 상태를 확인한다. -3. `작업계획/000-작업카드-템플릿.md`를 복사해 기능별 계획을 작성한다. -4. 구현 또는 수정 후 `보고/YYYY-MM-DD/`에 결과를 기록한다. -5. 외부 범위 이슈는 `이슈.md`, 개선 제안은 `제안.md`, 반복 개선점은 `개선사항.md`에 남긴다. diff --git a/apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md b/apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md deleted file mode 100644 index 5997c0649..000000000 --- a/apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md +++ /dev/null @@ -1,62 +0,0 @@ -# 노후관로관리 브라우저 수동 검증 체크리스트 및 네트워크 증적 양식 - -## 1. 대상 화면 -- 화면명: 노후관로정비사업 / 노후관로관리 -- 우선 검증 라우트: `/oper/oldPp` -- 관련 구현: `apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx` -- 관련 BE API: `GET /sewerage-service/api/v1/dcrpPpl` -- 관련 요구사항 근거: `apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md` - -## 2. 사전 조건 -| 항목 | 값 | -| --- | --- | -| 검증 일시 | | -| 검증자 | | -| 브라우저 | Chrome / Edge | -| FE URL | | -| BE URL | | -| 로그인 계정/권한 | | -| 테스트 데이터 기준 | | - -## 3. 수동 검증 체크리스트 -| 번호 | 단계 | 조작 | 기대 결과 | 결과 | 증적 | -| --- | --- | --- | --- | --- | --- | -| 1 | 화면 진입 | `/oper/oldPp` 또는 메뉴로 진입 | 검색 영역, 조회/등록 버튼, 목록 영역이 표시된다. | | 스크린샷 | -| 2 | 초기 조회 | 화면 로드 후 기본 조회 완료 대기 | 로딩이 종료되고 목록 또는 빈 목록이 표시된다. | | Network | -| 3 | 기간 조회 | 등록기간을 변경하고 조회 클릭 | `fromRegDt`, `toRegDt`가 Query String에 반영된다. | | Network | -| 4 | 명칭 조회 | 파일명/사업명 입력 후 Enter 또는 조회 클릭 | `dcrpPplMtncBizNm`이 Query String에 반영된다. | | Network | -| 5 | 페이지 이동 | 페이지네이션 클릭 | `page`가 변경되고 목록이 갱신된다. | | Network | -| 6 | 행 선택 | 목록 행 클릭 | 선택 행의 첨부 식별자가 상태에 반영되고 표 모달이 열린다. | | 스크린샷 | -| 7 | 데이터 없음 | 결과가 없는 조건으로 조회 | 오류 없이 빈 목록/0건 상태가 표시된다. | | 스크린샷 | -| 8 | 오류 응답 | BE 중지 또는 권한 오류 환경에서 조회 | 무한 로딩 없이 오류 로그/안내를 확인한다. | | Console/Network | - -## 4. 네트워크 응답 증적 양식 -| 항목 | 기록 | -| --- | --- | -| Request Name | | -| Method | `GET` | -| Request URL | `/sewerage-service/api/v1/dcrpPpl` | -| Query String | `fromRegDt=`, `toRegDt=`, `dcrpPplMtncBizNm=`, `auth=`, `page=`, `size=` | -| Status Code | | -| Response Headers 주요값 | `content-type`, `cache-control` | -| Response Body 주요 필드 | `content`, `totalElements`, `number`, `size`, `sort` | -| content 첫 행 주요 필드 | `dcrpPplMtncBizSn`, `mngYr`, `mngQu`, `atflId`, `dcrpPplMtncBizNm`, `rgtrNm` | -| 기대 결과 | Page 구조가 유지되고 JSON 파싱 오류가 없어야 한다. | -| 실제 결과 | | -| HAR/스크린샷 경로 | | - -## 5. 네트워크 증적 예시 -```text -GET /sewerage-service/api/v1/dcrpPpl?fromRegDt=2026-04-10&toRegDt=2026-04-17&dcrpPplMtncBizNm=&auth=&page=0&size=10 -Status: 200 -Response check: -- content: array -- totalElements: number -- number: 0 -- size: 10 -``` - -## 6. 이슈 기록 양식 -| 번호 | 증상 | 재현 단계 | 기대 | 실제 | 우선순위 | 담당 | 링크 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | | | | | | | | diff --git a/apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md b/apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md deleted file mode 100644 index cd3d9c231..000000000 --- a/apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md +++ /dev/null @@ -1,45 +0,0 @@ -# 007. 멀티에이전트 운영 기준 - -## 목적 -- `sn` 2차사업 프론트 작업을 BE, FE, QA 역할과 안전하게 연결하기 위한 기준임 -- AI_SAM `refactoring` 브랜치의 병렬 분업 구조를 참고하되, `sn` 앱과 허용된 `business_components` 범위를 우선함 - -## 프론트 역할 범위 -| 역할 | 주 책임 | 직접 수정 가능 범위 | 직접 수정 금지 범위 | -| --- | --- | --- | --- | -| FE | 화면, 컴포넌트, 훅, 상태관리, API 호출부, 타입 | `apps/sn/**`, `packages/common/business_components/**` | 타 앱, `business_components` 외 공통 패키지, 루트 `docs/**` | -| BE | API 계약, 요청/응답 DTO, 서비스 흐름, DB 영향 | 백엔드 저장소 기준 | 프론트 저장소 직접 수정은 별도 합류 기준 필요 | -| QA | 빌드, 브라우저 조작, 네트워크 응답, 회귀 포인트 확인 | 검증 문서, 결과보고 | 기능 코드 임의 수정 | - -## FE 작업 시작 입력 -- 화면명과 진입 경로 -- 사용자 조작 흐름 -- 호출 API와 응답 필드 -- `apps/sn` 수정 파일 후보 -- `business_components` 수정 필요 여부 -- 허용 범위 밖 수정 필요 여부 -- 브라우저 검증 URL과 테스트 데이터 -- 완료 조건 - -## BE 합류 필요 조건 -- API 경로, method, 요청 파라미터, 응답 필드가 화면 요구사항과 불일치함 -- 오류 응답 구조 또는 HTTP 상태 코드 처리가 화면 요구사항과 충돌함 -- 저장/삭제/분석 실행이 DB 스키마 또는 데이터 보정에 의존함 -- 권한, 인증, 외부 연동 기준 확인이 필요함 - -## QA 합류 필요 조건 -- `sn` 빌드 확인 필요 -- `business_components` 변경으로 타 앱 영향 가능성이 있음 -- 브라우저 화면 조작 또는 네트워크 응답 확인이 완료 조건에 포함됨 -- 로그인, 권한, 테스트 데이터 제약으로 수동 확인 절차가 필요함 - -## 병렬 작업 기준 -- `apps/sn` 화면 구현과 백엔드 API 구현은 API 계약 초안이 고정된 뒤 병렬 진행 가능함 -- `business_components` 변경은 `sn` 화면 요구사항을 만족하는 최소 범위로 제한함 -- `@common/components`, `@common/network`, `@common/auth`, `@common/config` 문제가 원인으로 보여도 직접 수정하지 않고 이슈로 기록함 -- 같은 파일은 한 역할만 수정함 - -## 검증 보고 기준 -- FE는 빌드 명령, 화면 URL, 사용자 조작, 기대 결과, 네트워크 응답 확인 기준을 남김 -- BE는 API 요청/응답 예시와 DB 영향 여부를 남김 -- QA는 실행한 명령, 통과/실패 여부, 재현 절차, 남은 위험을 남김 diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/개선사항.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/개선사항.md deleted file mode 100644 index bbf213bbb..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/개선사항.md +++ /dev/null @@ -1,5 +0,0 @@ -# 2026-04-16 개선 기록 - -## 반복 개선점 -- 추정: 기능별 작업이 누적되면 `작업목록.md`에 화면, API, 검증 상태 컬럼을 추가해 진행률을 더 쉽게 볼 수 있다. -- 추정: 브라우저 테스트 절차가 반복되면 `기준문서/007-브라우저점검목록.md`를 추가해 공통 체크리스트로 분리할 수 있다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/결과보고.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/결과보고.md deleted file mode 100644 index a325e1585..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/결과보고.md +++ /dev/null @@ -1,51 +0,0 @@ -# 2026-04-16 작업 보고 - -## SN-P2-HARNESS-001 - -### 작업 목적 -- 확인됨: `sn` 2차사업 작업을 기능 단위 작업카드와 일자별 기록으로 관리하기 위한 경량 하네스 문서 구조를 만든다. 근거: `apps/sn/AGENTS.md` - -### 수정 파일 목록 -- 확인됨: `apps/sn/docs/AI코딩하네스/개요.md` -- 확인됨: `apps/sn/docs/AI코딩하네스/작업목록.md` -- 확인됨: `apps/sn/docs/AI코딩하네스/기준문서/*` -- 확인됨: `apps/sn/docs/AI코딩하네스/작업계획/*` -- 확인됨: `apps/sn/docs/AI코딩하네스/보고/*` - -### 변경 요약 -- 확인됨: 프로젝트 기준, 화면 기준, API 연동 기준, 컴포넌트 기준, 상태관리 기준, 검증 기준 문서를 추가했다. -- 확인됨: 기능별 작업카드 템플릿과 일자별 기록 파일을 추가했다. - -### 영향 범위 -- 확인됨: 문서만 추가했으므로 런타임 동작에는 영향이 없다. -- 확인됨: `apps/sn` 하위 문서만 추가했으며 `packages/common/business_components`는 변경하지 않았다. - -### 검증 방법 -- 확인됨: 문서 파일 생성 여부를 확인한다. -- 확인됨: 코드 변경이 없으므로 빌드 검증은 수행하지 않아도 된다. - -### 롤백 방법 -- 확인됨: `apps/sn/docs/AI코딩하네스` 하위에서 이번에 추가한 문서 파일을 제거하면 된다. - -### 남은 리스크 -- 확인 필요: 2차사업 기능별 실제 작업카드는 요구사항과 화면설계서 기준으로 별도 작성해야 한다. -- 확인 필요: 브라우저 검증 URL과 테스트 계정은 실행 환경 기준으로 확정해야 한다. - -## SN-DOC-KO-001 - -### 작업 목적 -- 확인됨: 프론트엔드 문서 폴더와 Markdown 파일명을 한글 기준으로 정리하고 팀 공유를 위해 Git 추적 대상에 포함한다. - -### 변경 요약 -- 확인됨: 루트 `docs`의 기존 문서 파일명을 한글명으로 변경했다. -- 확인됨: `docs/bbs`는 `docs/게시판`, `docs/dockerbuild`는 `docs/도커빌드`로 변경했다. -- 확인됨: `apps/sn/docs/phase2`는 `apps/sn/docs/AI코딩하네스`로 변경했다. -- 확인됨: 문서 전용 파일명은 한글로 변경하되, `docs/도커빌드/Dockerfile`, `front_docker_build.sh`, `next.config.ts`는 실행 의미가 있어 파일명을 유지했다. - -### 영향 범위 -- 확인됨: 문서 경로와 문서 내부 참조만 변경했다. -- 확인됨: 프론트 런타임, 빌드 설정, API 계약에는 영향이 없다. - -### 검증 방법 -- 확인됨: 문서 파일 경로를 조회해 한글 경로로 이동된 것을 확인한다. -- 확인됨: `git status --short`에서 문서 파일이 추적 대상 상태로 표시되는지 확인한다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/이슈.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/이슈.md deleted file mode 100644 index 25f52e360..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/이슈.md +++ /dev/null @@ -1,8 +0,0 @@ -# 2026-04-16 이슈 - -## 외부 범위 이슈 -- 확인 필요: 현재 등록된 외부 범위 이슈는 없다. - -## 미확정 항목 -- 확인 필요: 2차사업 최종 화면명, 메뉴명, URL은 화면설계서와 실행 환경 기준으로 확정해야 한다. -- 확인 필요: 기능별 테스트 계정, 권한, 데이터는 환경별로 별도 확인해야 한다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/작업목록.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/작업목록.md deleted file mode 100644 index c8c90bac6..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/작업목록.md +++ /dev/null @@ -1,10 +0,0 @@ -# 2026-04-16 작업 항목 - -| ID | 상태 | 작업명 | 범위 | 결과 | -| --- | --- | --- | --- | --- | -| SN-P2-HARNESS-001 | 완료 | `sn` 2차사업 경량 하네스 문서 구조 생성 | `apps/sn/docs/AI코딩하네스` | 문서 골격 생성 | -| SN-DOC-KO-001 | 완료 | 프론트 문서 폴더/파일명 한글화 | `docs`, `apps/sn/docs` | 문서 경로 한글화 및 Git 추적 확인 | - -## 확인 사항 -- 확인됨: 프론트엔드 하네스 문서는 `apps/sn/docs/AI코딩하네스` 하위에 생성했다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 소스 코드, API 계약, DB 스키마는 변경하지 않았다. 근거: 작업 변경 범위 diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/제안.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-16/제안.md deleted file mode 100644 index be6ea5853..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-16/제안.md +++ /dev/null @@ -1,5 +0,0 @@ -# 2026-04-16 제안 - -## 후속 제안 -- 추정: 기능 개발 착수 전 `작업계획/SN-P2-001-*.md` 형식으로 작업카드를 먼저 작성하면 백엔드와 프론트엔드 변경 범위를 분리해서 추적하기 쉽다. -- 추정: 백엔드 API 변경이 동반되는 작업은 백엔드 작업카드 ID와 프론트 작업카드 ID를 서로 참조하는 방식이 좋다. diff --git a/apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md b/apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md deleted file mode 100644 index 05b870571..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md +++ /dev/null @@ -1,45 +0,0 @@ -# 2026-04-17 결과보고 - -## 작업 목적 -- `sn` 2차사업 고도화 작업을 멀티에이전트로 진행하기 전에 프론트 문서 기준과 공통 비즈니스 컴포넌트 문서의 오해 요소를 정리한다. - -## 수정 대상 파일 -- `apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md` -- `apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md` -- `apps/sn/docs/AI코딩하네스/기준문서/007-멀티에이전트운영.md` -- `apps/sn/docs/AI코딩하네스/개요.md` -- `apps/sn/docs/AI코딩하네스/작업목록.md` -- `apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md` -- `apps/sn/docs/AI코딩하네스/한글경로-영향.md` -- `apps/sn/app/html/AGENTS.md` -- `packages/common/business_components/README.md` -- `packages/common/business_components/src/form.md` -- `packages/common/business_components/src/chart.md` -- `packages/common/business_components/src/ui/search_form_left/searchFormLeft.md` -- `apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md` - -## 변경 내용 요약 -- 폐기된 `phase2` 문서 경로를 `apps/sn/docs/AI코딩하네스/작업계획/` 및 `apps/sn/docs/` 하위 주제별 폴더 기준으로 수정했다. -- 백엔드 응답표준화 문서 참조를 실제 한글 경로로 수정했다. -- 프론트 작업카드 템플릿에 쓰기 허용/금지 범위, 충돌 주의 파일, BE/FE/QA 산출물 항목을 추가했다. -- 프론트 기준문서에 `sn` 작업용 멀티에이전트 운영 기준을 추가하고, 하네스 개요와 작업목록에 연결했다. -- 한글 경로 영향 문서가 과거 기록임을 명시하고, 루트 `docs/**`를 승인 없이 수정하지 않는 기준을 덧붙였다. -- `business_components` 문서의 오타, 잘못된 form 설명, 깨진 chart 예시, `fo` 기준 예시의 적용 주의 문구를 보완했다. - -## 영향 범위 -- 코드, API, DB 스키마 영향은 없다. -- `sn` 작업자가 문서 기준을 따라 작업할 때 수정 가능 범위와 작업카드 작성 기준이 더 명확해진다. -- `business_components` 문서 예시를 참고할 때 타 앱 기준을 `sn`에 그대로 적용하는 위험이 줄어든다. -- 백엔드 API/DB 영향이 있는 화면 작업에서 BE/FE/QA 합류 조건을 더 빨리 판단할 수 있다. - -## 테스트 / 검증 방법 -- Markdown 문서의 주요 참조 경로와 문구를 수동 확인했다. -- 빌드 또는 테스트는 코드 변경이 아니므로 수행하지 않았다. - -## 롤백 방법 -- 각 Markdown 파일을 이전 문구로 되돌린다. -- `apps/sn/docs/AI코딩하네스/보고/2026-04-17/결과보고.md`를 삭제한다. - -## 남은 리스크 -- `business_components`의 실제 컴포넌트 API와 문서 예시 전체가 완전히 일치하는지는 별도 코드 기준 검증이 필요하다. -- 로그인, 권한, 테스트 데이터가 확정되지 않은 기능은 브라우저 검증 항목을 작업카드에서 다시 확정해야 한다. diff --git a/apps/sn/docs/AI코딩하네스/보고/개요.md b/apps/sn/docs/AI코딩하네스/보고/개요.md deleted file mode 100644 index 599676d4a..000000000 --- a/apps/sn/docs/AI코딩하네스/보고/개요.md +++ /dev/null @@ -1,16 +0,0 @@ -# 작업 기록 - -## 목적 -이 디렉터리는 `sn` 2차사업 작업의 일자별 진행 기록을 남긴다. - -## 파일 구성 -- `작업목록.md`: 해당 일자의 작업 항목과 상태 -- `결과보고.md`: 수행 결과와 검증 결과 -- `이슈.md`: 외부 범위, 미확정, 차단 이슈 -- `제안.md`: 후속 제안 -- `개선사항.md`: 반복 작업 개선점 - -## 기록 원칙 -- 확인됨: 작업 결과는 변경 파일, 영향 범위, 검증 방법, 롤백 방법을 포함한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 권한 밖 경로 문제는 직접 수정하지 않고 이슈로 기록한다. 근거: `apps/sn/AGENTS.md` -- 추정: 백엔드와 함께 진행하는 기능은 백엔드 작업 기록과 프론트 작업 기록을 서로 참조하면 회귀 추적에 유리하다. 근거: 백엔드 `docs/AI코딩하네스` 구조 diff --git a/apps/sn/docs/AI코딩하네스/작업계획/001-SN-P2-노후관로관리-작업카드.md b/apps/sn/docs/AI코딩하네스/작업계획/001-SN-P2-노후관로관리-작업카드.md deleted file mode 100644 index 5b6bb4ce1..000000000 --- a/apps/sn/docs/AI코딩하네스/작업계획/001-SN-P2-노후관로관리-작업카드.md +++ /dev/null @@ -1,55 +0,0 @@ -# SN-P2-001 노후관로관리 작업카드 샘플 - -## 1. 작업 개요 -- 작업 ID: `SN-P2-001` -- 작업명: 노후관로관리 화면 조회/증적 기준 정리 -- 상태: 샘플 작성 -- 담당 역할: FE -- 관련 역할: BE, QA -- 대상 범위: `apps/sn/**` -- 근거 문서: - - `apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md` - - `D:/workspace/IIPBS/sewerage-service/docs/실행명세/기능/21-dcrp-ppl-관리.md` - - `D:/workspace/IIPBS/sewerage-service/docs/실행명세/기능/60-관로통계.md` - -## 2. 작업 목적 -- 노후관로정비사업 화면의 조회 조건과 백엔드 `GET /api/v1/dcrpPpl` 계약을 카드에 고정한다. -- GIS 노후관로관리 시나리오가 본 화면으로 확장될 때 필요한 API/브라우저 증적 항목을 미리 둔다. - -## 3. 수정 대상 파일 -| 구분 | 파일 경로 | 수정 여부 | 근거 | -| --- | --- | --- | --- | -| 화면 | `apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx` | 필요 시 최소 수정 | 노후관로정비사업 조회 화면 | -| 테이블 | `apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx` | 필요 시 최소 수정 | 행 선택 후 표 모달 | -| API 호출 | `apps/sn/app/components/lib/common/apiHelpers.ts` | 원칙상 변경 없음 | 기존 `searchData` 사용 | -| 공통 컴포넌트 | `packages/common/business_components/**` | 변경 없음 | 이번 카드에서는 직접 수정 금지 | - -## 4. 화면 흐름 -- 진입: `노후관로정비사업` 화면 또는 라우트 `/oper/oldPp` -- 기본 조회: 등록기간 기본값으로 `GET /api/v1/dcrpPpl` 호출 -- 검색 조건: 등록기간, 등록자 입력값, 파일명/사업명 입력값 -- 결과: 목록 테이블, 총 건수, 페이지 이동 -- 행 선택: 첨부 파일명과 식별자를 상태에 보관하고 표 모달을 연다. -- 파일 작업: 다운로드/삭제/등록은 외부 첨부 서비스와 데이터 변경이 있으므로 QA 승인 데이터가 필요하다. - -## 5. API 연동 -| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | -| --- | --- | --- | --- | --- | --- | -| 목록 조회 | GET | `/sewerage-service/api/v1/dcrpPpl` | `fromRegDt`, `toRegDt`, `dcrpPplMtncBizNm`, `auth`, `page`, `size` | Page 구조 | 확인됨 | -| 등록 | POST | `/sewerage-service/api/v1/dcrpPpl` | `mngYr`, `mngQu`, `atflCd`, `atflId`, `rgtr`, `dcrpPplMtncBizNm` | 단건 DTO | 미검증 | -| 삭제 | DELETE | `/sewerage-service/api/v1/dcrpPpl/{dcrpPplMtncBizSn}` | path id | 200 empty | 미검증 | -| 파일 다운로드 | GET | `/attach-service/api/v1/download/{atflId}` | path id | 파일 | 미검증 | - -## 6. 검증 방법 -- 빌드: `pnpm --filter sn run build` -- 브라우저: `/oper/oldPp` 진입 후 조회 버튼, 페이지 이동, 행 선택, 모달 확인 -- 네트워크: `GET /sewerage-service/api/v1/dcrpPpl` 요청 URL, Query String, Status, Response JSON 기록 -- 상세 양식: `apps/sn/docs/AI코딩하네스/검증/001-노후관로관리-브라우저-네트워크-증적.md` - -## 7. 롤백 방법 -- 이번 카드에서 화면 코드가 변경되지 않았다면 문서만 제거한다. -- 화면 코드 변경이 발생한 경우 `oldPp/page.tsx`와 관련 모달 변경분을 되돌리고 빌드를 재실행한다. - -## 8. 예상 위험 -- `auth` 전달 방식과 사용자명 보강은 인증/권한 상태에 따라 결과가 달라질 수 있다. -- 등록/삭제/다운로드는 데이터 변경과 첨부 서비스 의존이 있어 수동 검증 전 테스트 데이터가 필요하다. diff --git a/apps/sn/docs/AI코딩하네스/작업계획/개요.md b/apps/sn/docs/AI코딩하네스/작업계획/개요.md deleted file mode 100644 index c9900ae47..000000000 --- a/apps/sn/docs/AI코딩하네스/작업계획/개요.md +++ /dev/null @@ -1,17 +0,0 @@ -# 작업카드 관리 - -## 목적 -이 디렉터리는 `sn` 2차사업 기능별 실행 계획을 보관한다. - -작업카드는 구현 전에 작성하며, 수정 범위, API 영향, 화면 영향, 검증 방법, 롤백 방법을 먼저 고정한다. - -## 작성 규칙 -- 확인됨: 기능 단위로 문서를 만든다. 근거: 이 하네스 운영 문서 -- 확인됨: 직접 수정 대상은 `apps/sn/**`와 `packages/common/business_components/**`로 제한한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 권한 밖 수정 필요성은 작업카드에 직접 구현 항목으로 넣지 않고 외부 이슈로 기록한다. 근거: `apps/sn/AGENTS.md` -- 추정: 백엔드 동시 변경이 필요한 기능은 백엔드 `docs/AI코딩하네스/작업계획` 작업카드 번호를 함께 적는 것이 추적에 유리하다. 근거: 백엔드 `docs/AI코딩하네스` 구조 - -## 파일명 규칙 -- 권장 형식: `SN-P2-001-기능명.md` -- 기능명은 화면명 또는 업무명을 기준으로 작성한다. -- 한 작업카드에 여러 화면을 무리하게 묶지 않는다. diff --git a/apps/sn/docs/AI코딩하네스/작업목록.md b/apps/sn/docs/AI코딩하네스/작업목록.md deleted file mode 100644 index 133371799..000000000 --- a/apps/sn/docs/AI코딩하네스/작업목록.md +++ /dev/null @@ -1,19 +0,0 @@ -# sn 2차사업 작업 목록 - -## 상태 값 -- `대기`: 아직 착수하지 않은 작업 -- `진행`: 현재 분석 또는 구현 중인 작업 -- `검증`: 구현 후 빌드, 브라우저, API 응답을 확인하는 작업 -- `완료`: 검증과 기록이 끝난 작업 -- `보류`: 외부 범위 또는 추가 확인이 필요한 작업 - -## 작업 목록 -| ID | 상태 | 작업명 | 범위 | 근거 | 비고 | -| --- | --- | --- | --- | --- | --- | -| SN-P2-000 | 대기 | 2차사업 기능 작업카드 등록 | `apps/sn/docs/AI코딩하네스/작업계획` | 확인됨: `apps/sn/AGENTS.md` | 기능별 작업 착수 전 작성 | -| SN-P2-HARNESS-002 | 완료 | AI_SAM 최신 하네스 구조 재확인 및 멀티에이전트 운영 기준 반영 | `apps/sn/docs/AI코딩하네스/기준문서` | 확인됨: AI_SAM `refactoring` 최신 HEAD | BE/FE/QA 합류 기준 추가 | - -## 운영 규칙 -- 확인됨: 작업은 `apps/sn/**`와 `packages/common/business_components/**` 범위 안에서만 직접 수정한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: 다른 앱 또는 공통 패키지 문제가 발견되면 이 파일에 직접 작업으로 넣지 않고 `보고/YYYY-MM-DD/이슈.md`에 기록한다. 근거: `apps/sn/AGENTS.md` -- 추정: 실제 기능 작업은 2차사업 요구사항, 화면설계서, 백엔드 API 응답 근거를 함께 연결해야 한다. 근거: `apps/sn/AGENTS.md` diff --git a/apps/sn/docs/AI코딩하네스/한글경로-영향.md b/apps/sn/docs/AI코딩하네스/한글경로-영향.md deleted file mode 100644 index f2da3ec0b..000000000 --- a/apps/sn/docs/AI코딩하네스/한글경로-영향.md +++ /dev/null @@ -1,56 +0,0 @@ -# 한글 폴더 및 Markdown 파일명 영향 검토 - -> 이 문서는 2026-04-16 문서 경로 정리 당시의 영향 검토 기록이다. 이후 `sn` 작업에서 새 문서를 만들 때는 `apps/sn/AGENTS.md` 기준에 따라 `apps/sn/docs/` 하위만 사용하고, 프론트엔드 루트 `docs/**`는 사용자 승인 없이 수정하지 않는다. - -> 이 문서는 2026-04-16 문서 경로 정리 당시의 영향 검토 기록이다. 이후 `sn` 작업에서 새 문서를 만들 때는 `apps/sn/AGENTS.md` 기준에 따라 `apps/sn/docs/` 하위만 사용하고, 프론트엔드 루트 `docs/**`는 사용자 승인 없이 수정하지 않는다. - -## 1. 결론 - -- 프론트엔드 문서 전용 경로는 한글 폴더명과 한글 `.md` 파일명을 사용해도 Codex 작업 이해에는 문제가 없음 -- 현재 변경 범위는 루트 `docs/`와 `apps/sn/docs/` 하위 문서 경로에 한정되므로 프론트 런타임 영향은 없음 -- 빌드, 배포, 앱 라우팅, API 호출 경로는 변경하지 않음 - -## 2. 적용한 한글 경로 - -- `docs/게시판/` -- `docs/도커빌드/` -- `docs/앱라우트.md` -- `docs/인증서비스-리팩토링가이드.md` -- `docs/CS프로젝트구조.md` -- `docs/프론트디버깅.md` -- `docs/개선계획.md` -- `docs/요구사항.md` -- `apps/sn/docs/AI코딩하네스/` -- `apps/sn/docs/AI코딩하네스/기준문서/` -- `apps/sn/docs/AI코딩하네스/작업계획/` -- `apps/sn/docs/AI코딩하네스/보고/` - -## 3. 파일명을 유지한 항목 - -- `docs/도커빌드/Dockerfile` -- `docs/도커빌드/front_docker_build.sh` -- `docs/도커빌드/next.config.ts` -- `docs/IIPBS_단어목록.md` - -## 4. 영향 없음 - -- Next.js 앱 실행 -- Turborepo 빌드 -- pnpm workspace 설정 -- Dockerfile, PM2, Gitea workflow -- API 계약과 백엔드 연동 경로 -- `apps/sn` 화면 라우팅 - -## 5. 주의 필요 - -- 외부 문서 링크가 기존 영문 경로를 직접 참조하는 경우 새 한글 경로로 수정 필요 -- CI 또는 배포 스크립트가 `docs/dockerbuild`를 직접 참조하는 경우 새 `docs/도커빌드` 경로로 수정 필요 -- 일부 콘솔에서는 한글 경로가 이스케이프 문자열로 보일 수 있음 -- URL로 공유되는 Markdown 링크는 한글 경로가 퍼센트 인코딩되어 길게 표시될 수 있음 - -## 6. 운영 기준 - -- 코드, 라우팅, 빌드, 배포 스크립트가 직접 참조하는 파일명은 영문 유지 권장 -- 문서 전용 폴더와 `.md` 파일명은 한글 사용 가능 -- 신규 `sn` 문서는 `apps/sn/docs/` 바로 아래 주제별 한글 폴더로 관리 -- `phase2` 같은 단계 분리 폴더는 사용하지 않음 diff --git a/apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md b/apps/sn/docs/기준문서/001-프로젝트.md similarity index 64% rename from apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md rename to apps/sn/docs/기준문서/001-프로젝트.md index be374d764..3b076b4c9 100644 --- a/apps/sn/docs/AI코딩하네스/기준문서/001-프로젝트.md +++ b/apps/sn/docs/기준문서/001-프로젝트.md @@ -17,6 +17,13 @@ ## 문서 기준 - 확인됨: `sn` 관련 2차사업 작업 문서는 `apps/sn/docs/` 바로 아래 주제별 한글 폴더로 관리한다. 근거: `apps/sn/AGENTS.md` -- 확인됨: AI 코딩 하네스 작업카드는 `apps/sn/docs/AI코딩하네스/작업계획/` 하위에 둔다. 근거: `apps/sn/AGENTS.md`, `apps/sn/docs/AI코딩하네스/개요.md` -- 확인됨: `phase2` 같은 별도 단계 폴더는 신규 문서 경로로 사용하지 않는다. 근거: `apps/sn/AGENTS.md`, `apps/sn/docs/AI코딩하네스/한글경로-영향.md` -- 추정: 백엔드와 함께 진행되는 기능은 백엔드 `docs/AI코딩하네스/작업계획/` 작업카드와 프론트 `apps/sn/docs/AI코딩하네스/작업계획/` 작업카드를 서로 참조하는 방식이 안전하다. 근거: `apps/sn/AGENTS.md`, 백엔드 `AGENTS.md` +- 확인됨: 프론트 작업카드는 `apps/sn/docs/작업계획/작업카드-템플릿.md`를 기준으로 기능 폴더 아래에 작성한다. 근거: `apps/sn/AGENTS.md` +- 확인됨: `phase2` 같은 별도 단계 폴더는 신규 문서 경로로 사용하지 않는다. 근거: `apps/sn/AGENTS.md` +- 추정: 백엔드와 함께 진행되는 기능은 백엔드 `docs/작업계획/작업카드-템플릿.md`와 프론트 `apps/sn/docs/작업계획/작업카드-템플릿.md`를 각각 기능 폴더 아래에서 서로 참조하는 방식이 안전하다. 근거: `apps/sn/AGENTS.md`, 백엔드 `AGENTS.md` + +## 공통 동기화 기준 +- 새 채팅방에서 프론트 작업을 시작할 때는 아래 문서를 먼저 확인한다. + - `COMMON_DOCS_ROOT/작업계획/공통-작업동기화/002-공통-작업현황판.md` + - `COMMON_DOCS_ROOT/작업계획/공통-작업동기화/003-공통-파일점유현황.md` + - `COMMON_DOCS_ROOT/작업계획/공통-작업동기화/004-공통-결정로그.md` +- 같은 기능을 다른 채팅방에서 이어받는 경우 작업 ID, 현재 상태, 점유 파일을 먼저 갱신한다. diff --git a/apps/sn/docs/AI코딩하네스/기준문서/002-화면맵.md b/apps/sn/docs/기준문서/002-화면맵.md similarity index 97% rename from apps/sn/docs/AI코딩하네스/기준문서/002-화면맵.md rename to apps/sn/docs/기준문서/002-화면맵.md index b7b8868e4..d65e883c6 100644 --- a/apps/sn/docs/AI코딩하네스/기준문서/002-화면맵.md +++ b/apps/sn/docs/기준문서/002-화면맵.md @@ -16,4 +16,4 @@ ## 확인 필요 항목 - 확인 필요: 2차사업 최종 화면명과 메뉴 트리는 화면설계서 기준으로 확정해야 한다. 근거: 2차사업 화면설계서 -- 확인 필요: 실제 운영 메뉴 권한과 라우팅 정책은 실행 환경에서 확인해야 한다. 근거: `apps/sn` 소스, 브라우저 검증 +- 확인 필요: 실제 운영 메뉴 권한과 라우팅 정책은 실행 환경에서 확인해야 한다. 근거: `apps/sn` 소스, 브라우저 검증 \ No newline at end of file diff --git a/apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md b/apps/sn/docs/기준문서/003-API사용.md similarity index 84% rename from apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md rename to apps/sn/docs/기준문서/003-API사용.md index 1c3471116..6ecdfbcfc 100644 --- a/apps/sn/docs/AI코딩하네스/기준문서/003-API사용.md +++ b/apps/sn/docs/기준문서/003-API사용.md @@ -4,6 +4,7 @@ - 확인됨: 프론트엔드는 백엔드 `sewerage-service` API와 연동한다. 근거: `apps/sn/AGENTS.md` - 확인됨: 백엔드 응답 구조나 API 계약을 프론트엔드에서 임의로 변경했다고 가정하지 않는다. 근거: `apps/sn/AGENTS.md` - 확인됨: API 계약 변경이 필요하면 백엔드 작업으로 분리하고 영향 범위를 먼저 정리한다. 근거: `apps/sn/AGENTS.md` +- 확인됨: 신규 또는 변경 API URL은 백엔드 기준에 맞춰 업무 리소스 path segment를 lowerCamelCase로 맞춘다. 근거: `COMMON_DOCS_ROOT/기준문서/010-AI개발작업흐름.md`, `BE_DOCS_ROOT/기준문서/005-API정책.md` ## 응답 처리 기준 - 확인됨: 백엔드 공통 응답은 `status`, `message`, `data`, `timestamp` 형태로 내려오는 API가 존재한다. 근거: 사용자 확인 응답 예시, 백엔드 `docs/응답표준화/02-백엔드응답규칙.md` @@ -13,6 +14,7 @@ ## 작업카드에 남길 항목 - 확인 필요: HTTP method - 확인 필요: URL +- 확인 필요: 신규 또는 변경 URL의 lowerCamelCase 준수 여부 - 확인 필요: query/body 파라미터 - 확인 필요: 정상 응답 예시 - 확인 필요: 오류 응답 처리 방식 diff --git a/apps/sn/docs/AI코딩하네스/기준문서/004-컴포넌트정책.md b/apps/sn/docs/기준문서/004-컴포넌트정책.md similarity index 97% rename from apps/sn/docs/AI코딩하네스/기준문서/004-컴포넌트정책.md rename to apps/sn/docs/기준문서/004-컴포넌트정책.md index 51c76c4c1..864726d4a 100644 --- a/apps/sn/docs/AI코딩하네스/기준문서/004-컴포넌트정책.md +++ b/apps/sn/docs/기준문서/004-컴포넌트정책.md @@ -19,4 +19,4 @@ - 확인 필요: 브라우저 확인 화면 ## 외부 범위 이슈 -- 확인됨: 권한 밖 공통 패키지 수정이 필요해 보이면 직접 고치지 않고 문제 원인, 수정 필요 경로, `sn` 영향 범위, 타 앱 영향 가능성을 문서화한다. 근거: `apps/sn/AGENTS.md` +- 확인됨: 권한 밖 공통 패키지 수정이 필요해 보이면 직접 고치지 않고 문제 원인, 수정 필요 경로, `sn` 영향 범위, 타 앱 영향 가능성을 문서화한다. 근거: `apps/sn/AGENTS.md` \ No newline at end of file diff --git a/apps/sn/docs/AI코딩하네스/기준문서/005-상태관리정책.md b/apps/sn/docs/기준문서/005-상태관리정책.md similarity index 56% rename from apps/sn/docs/AI코딩하네스/기준문서/005-상태관리정책.md rename to apps/sn/docs/기준문서/005-상태관리정책.md index a6192d573..8533b442c 100644 --- a/apps/sn/docs/AI코딩하네스/기준문서/005-상태관리정책.md +++ b/apps/sn/docs/기준문서/005-상태관리정책.md @@ -4,6 +4,10 @@ - 확인됨: 기존 `sn` 화면 흐름과 사용자 동작을 가능한 유지하면서 2차 요구사항을 반영한다. 근거: `apps/sn/AGENTS.md` - 확인됨: 복잡한 컴포넌트, 탭 분기, 폼 처리, 데이터 변환, 지도 연동, 공통 컴포넌트 조합 로직은 함수 또는 메서드 단위 주석을 상세히 작성한다. 근거: `apps/sn/AGENTS.md` - 추정: 화면별 상태는 기존 패턴을 우선 따르고, 새 전역 상태 구조는 필요성이 확인된 경우에만 검토해야 한다. 근거: `apps/sn/AGENTS.md` +- 확인됨: 서버 조회 결과와 화면 UI 상태는 용도를 구분한다. 서버 상태는 React Query 또는 화면 전용 조회 훅을 우선 검토하고, Zustand에는 화면 전역 UI 상태, 사용자 선택, 모달 상태, 장기간 공유되는 기준 데이터만 둔다. +- 확인됨: Zustand에서 서버 기준 데이터를 조회해 보관하는 경우에는 로딩, 오류, 중복 호출 방지, 재조회 조건을 함께 정의한다. +- 확인됨: 같은 의미의 기준 데이터 store를 두 계열 이상 유지하지 않는다. 기존 store가 겹치면 조회 출처, 데이터 shape, 필터링 규칙을 비교하고 하나의 기준으로 정리한다. +- 확인됨: React Query를 사용하는 Provider는 QueryClient 인스턴스가 렌더마다 새로 생성되지 않도록 안정적으로 유지한다. ## 화면 흐름 기록 항목 - 확인 필요: 초기 진입 시 호출되는 API @@ -11,6 +15,8 @@ - 확인 필요: 페이지네이션 또는 무한스크롤 방식 - 확인 필요: 선택 행 또는 상세 패널 상태 - 확인 필요: 등록, 수정, 삭제 후 재조회 방식 +- 확인 필요: 서버 상태 캐시, 전역 store, 컴포넌트 local state 중 어디에 데이터를 둘지 +- 확인 필요: 로딩, 오류, 중복 호출 방지, 재조회 조건 - 확인 필요: 팝업, 모달, 지도, 파일, 엑셀 흐름 - 확인 필요: 에러 메시지 표시 위치 diff --git a/apps/sn/docs/AI코딩하네스/기준문서/006-검증정책.md b/apps/sn/docs/기준문서/006-검증정책.md similarity index 96% rename from apps/sn/docs/AI코딩하네스/기준문서/006-검증정책.md rename to apps/sn/docs/기준문서/006-검증정책.md index 0796edd18..833d33a4a 100644 --- a/apps/sn/docs/AI코딩하네스/기준문서/006-검증정책.md +++ b/apps/sn/docs/기준문서/006-검증정책.md @@ -31,4 +31,4 @@ pnpm turbo run build --filter="...[origin/main]" ## 검증 기록 - 확인됨: 검증 결과는 `보고/YYYY-MM-DD/결과보고.md`에 기록한다. 근거: 이 하네스 운영 문서 -- 확인됨: 외부 범위 오류는 `보고/YYYY-MM-DD/이슈.md`에 기록한다. 근거: 이 하네스 운영 문서 +- 확인됨: 외부 범위 오류는 `보고/YYYY-MM-DD/이슈.md`에 기록한다. 근거: 이 하네스 운영 문서 \ No newline at end of file diff --git a/apps/sn/docs/기준문서/007-멀티에이전트운영.md b/apps/sn/docs/기준문서/007-멀티에이전트운영.md new file mode 100644 index 000000000..41406e465 --- /dev/null +++ b/apps/sn/docs/기준문서/007-멀티에이전트운영.md @@ -0,0 +1,13 @@ +# 007. 멀티에이전트운영 + +## 목적 +- 프론트 `sn` 앱 관점에서 멀티에이전트 작업 시 `fe` 역할이 따라야 하는 보조 기준을 정리한다. + +## 공통 기준 +- 공통 역할/합류/검증 기준은 `COMMON_DOCS_ROOT/기준문서\011-멀티에이전트운영.md`를 우선 따른다. +- 공통 개발 흐름은 `COMMON_DOCS_ROOT/기준문서\010-AI개발작업흐름.md`를 우선 따른다. + +## 프론트 보조 기준 +- `fe` 역할의 직접 수정 범위는 `apps/sn/**`, `packages/common/business_components/**` 이다. +- `@common/components`, `@common/network`, `@common/auth`, `@common/config`, 타 앱은 직접 수정하지 않는다. +- 프론트 결과 보고에는 화면 진입 경로, 사용자 조작, 빌드 결과, 네트워크 확인 기준, BE/QA 합류 필요사항을 포함한다. diff --git a/apps/sn/docs/기준문서/008-설계정책.md b/apps/sn/docs/기준문서/008-설계정책.md new file mode 100644 index 000000000..62e300a72 --- /dev/null +++ b/apps/sn/docs/기준문서/008-설계정책.md @@ -0,0 +1,25 @@ +# 008. 설계정책 + +## 1. 목적 +- `sn` 앱 화면, 상태, API 연동 설계를 변경할 때 판단 기준을 통일한다. +- 프론트 단독 변경과 백엔드 계약 변경을 구분해서 영향 범위를 명확히 한다. + +## 2. 기본 원칙 +- 화면 요구사항, 화면 흐름, API 응답 구조를 함께 보고 설계한다. +- `apps/sn/**` 안에서 해결 가능한 변경을 우선한다. +- 공통 컴포넌트 수정이 필요하면 `packages/common/business_components/**` 범위만 검토한다. +- 그 밖의 공통 패키지 수정이 필요해 보이면 직접 수정하지 말고 원인과 영향 범위를 먼저 문서화한다. + +## 3. 화면 설계 원칙 +- 화면 상태는 조회 조건, 목록 데이터, 상세 데이터, 로딩/에러 상태를 분리한다. +- 폼과 조회 조건은 화면 컴포넌트 내부에서 뒤섞지 말고 역할 단위로 분리한다. +- 지도, 표, 팝업이 함께 있는 화면은 사용자 흐름 기준으로 이벤트 책임을 나눈다. + +## 4. API 연동 원칙 +- 프론트는 실제 백엔드 DTO, API 명세, 에러 응답을 기준으로 맞춘다. +- 필드명 해석이 애매하면 프론트에서 임의 변환 규칙을 늘리기보다 계약을 먼저 확인한다. +- 목록/상세/다운로드 API는 요청 파라미터와 응답 구조를 문서에 남긴다. + +## 5. 공통 문서 참조 +- 공통 용어는 `COMMON_DOCS_ROOT/기준문서/003-공통용어사전.md`를 우선 참조한다. +- 공통 환경/빌드 이슈는 `COMMON_DOCS_ROOT/기준문서/004-환경빌드문제대응.md`를 함께 본다. diff --git a/apps/sn/docs/기준문서/009-네이밍규칙.md b/apps/sn/docs/기준문서/009-네이밍규칙.md new file mode 100644 index 000000000..39626ba75 --- /dev/null +++ b/apps/sn/docs/기준문서/009-네이밍규칙.md @@ -0,0 +1,24 @@ +# 009. 네이밍규칙 + +## 1. 목적 +- `sn` 앱에서 화면, 컴포넌트, 상태, API 관련 이름을 일관되게 유지한다. + +## 2. 파일/폴더 +- 화면 파일과 폴더는 기능 기준으로 묶고, 한 화면에서 여러 역할이 섞이면 하위 폴더로 분리한다. +- 문서 파일은 현재처럼 한글 제목을 사용할 수 있지만, 코드 파일은 기존 프로젝트 관례를 우선 따른다. +- 임시 파일명, `new`, `temp`, `test2` 같은 이름은 커밋 전에 정리한다. + +## 3. 컴포넌트 +- React 컴포넌트는 역할이 드러나는 명사를 사용한다. +- 화면 루트는 `Page`, 팝업은 `Dialog` 또는 `Modal`, 표는 `Table` 또는 `Grid`처럼 일관된 접미사를 사용한다. +- 공통화가 되지 않은 로컬 화면 조각에 과도하게 `Common` 접두사를 붙이지 않는다. + +## 4. 상태/함수 +- 조회 조건은 `search`, `filter`, `params`처럼 의미가 드러나는 이름을 사용한다. +- 이벤트 핸들러는 `handle*`, 파생 계산은 `get*` 또는 의미 있는 명사형으로 구분한다. +- 비동기 함수는 목적이 드러나도록 `fetch*`, `load*`, `download*` 등을 사용한다. + +## 5. API/데이터 +- 백엔드 계약 필드명은 임의 축약하지 않는다. +- 화면 표시용 변환 값은 원본 DTO와 구분되게 이름을 짓는다. +- Boolean 값은 `is`, `has`, `can` 접두사를 우선 사용한다. diff --git a/apps/sn/docs/기준문서/010-용어사전.md b/apps/sn/docs/기준문서/010-용어사전.md new file mode 100644 index 000000000..33ced4ae7 --- /dev/null +++ b/apps/sn/docs/기준문서/010-용어사전.md @@ -0,0 +1,105 @@ +# 010. 용어사전 + +## 1. 목적 +- `sn` 프론트 코드에서 실제로 사용되는 용어를 기준으로 화면 라벨, 라우트명, 컴포넌트명, API 필드명을 정리한다. +- 같은 의미에 여러 이름이 섞여 있는 부분을 드러내고, 작업 시 어떤 이름을 기준으로 삼아야 하는지 고정한다. + +## 2. 참조 기준 +- 공통 용어: `COMMON_DOCS_ROOT/기준문서/003-공통용어사전.md` +- 백엔드 원본 용어: `BE_DOCS_ROOT/기준문서/004-용어.csv` +- 프론트 네이밍 규칙: `FE_APP_DOCS_ROOT/기준문서/009-네이밍규칙.md` + +## 3. 정리 원칙 +- 화면 라벨은 사용자에게 익숙한 한글 업무 용어를 사용한다. +- API/DTO/DB와 직접 연결되는 식별자는 백엔드 계약 이름을 우선한다. +- 라우트나 로컬 컴포넌트에 이미 굳은 이름은 즉시 바꾸지 않고 `별칭`으로 관리한다. +- 신규 작업에서는 `기준 용어`만 새 이름으로 사용한다. +- `별칭`은 기존 코드 해석용으로만 남기고, 신규 파일/함수/변수에는 사용하지 않는다. + +## 4. 코드 분석으로 확인된 주요 용어군 +이번 검토에서 실제 `apps/sn` 코드에서 확인한 대표 용어군은 아래와 같다. + +- 사업/운영 화면 + - `oldPp`, `dcrpPpl`, `agedPipe` + - `floodPre`, `sbmrgPrevnt` + - `bizInfo` +- 지도/시설 화면 + - `swagePpl`, `swageMhl`, `swagePmstn`, `stofcbr` + - `swagePrcsFclt`, `swagePrcsZone`, `SwtrmtFclt` + - `WaterDepth` +- 분석 화면 + - `rdii`, `rdiiResult`, `result` + - `irsetting`, `fileupload` + - `infilChart`, `rdiiChart` +- 공통 기술 용어 + - `lyrStng`, `lyrTyNm`, `basicType`, `featureData` + +## 5. 프론트 기준 용어 매핑 +| 업무 의미 | 기준 용어 | 현재 코드 별칭 | 화면 라벨 | 사용 기준 | 상태 | +| --- | --- | --- | --- | --- | --- | +| 노후관로정비사업 | `dcrpPpl` | `oldPp` | 노후관로정비사업 | API/DTO/BE 계약은 `dcrpPpl`, 기존 route는 `oldPp` 유지 | 혼재 | +| 노후관로 지도 시나리오 | `agedPipe` | `oldPpl`, `dcrpPpl` | 노후관로 | HTML 시안/전용 화면 이름은 `agedPipe` 사용 가능, API 연동 이름으로 직접 쓰지 않음 | 혼재 | +| 침수예방사업 | `sbmrgPrevnt` | `floodPre` | 침수예방사업 | API/DTO는 `sbmrgPrevnt`, 기존 route는 `floodPre` 유지 | 혼재 | +| 사업정보 | `bizInfo` | 없음 | 사업정보 | API/화면 모두 `bizInfo` 사용 | 기준 확정 | +| 관로 | `swagePpl` | `ppl` | 하수관로 | 화면/컴포넌트는 `swagePpl`, 백엔드 필드/약어는 `ppl` 허용 | 기준 후보 | +| 맨홀 | `swageMhl` | `mhl`, `smh` | 하수맨홀 | 검색/표 컬럼은 `swageMhl`, 축약 내부 변수만 `mhl` 허용 | 혼재 | +| 펌프장 | `swagePmstn` | `pump`, `sewagePump`, `stormWaterPump` | 하수펌프장 | 화면/검색 기준은 `swagePmstn`, 로컬 컴포넌트 `sewagePump`는 기존 파일명 별칭 | 혼재 | +| 우수토실 | `stofcbr` | 없음 | 우수토실 | 기존 코드/DB 기준 `stofcbr` 유지 | 기준 확정 | +| 하수처리시설 | `swtrmtFclt` | `swagePrcsFclt` | 하수처리시설 | 시설 실체/응답 필드는 `swtrmtFclt`, 기존 검색 모달 `swagePrcsFclt`는 별칭 | 혼재 | +| 하수처리구역 | `swtrmtZone` | `swagePrcsZone` | 하수처리구역 | 응답/화면 기준은 `swtrmtZone`, 기존 검색 모달 `swagePrcsZone`는 별칭 | 혼재 | +| 수심 레이어 | `waterDepth` | 없음 | 수심 | UI/컴포넌트 기준 `waterDepth` 사용 | 기준 확정 | +| 레이어 설정 | `lyrStng` | `treeData` | 레이어 설정 | API/도메인 기준은 `lyrStng`, 화면 상태값은 `treeData` 허용 | 기준 후보 | +| RDII 분석 | `rdii` | `rdiiResult` | RDII 분석 / RDII 분석결과 | 기능명은 `rdii`, 결과 화면은 `rdiiResult` 분리 | 기준 확정 | +| 침입수 분석 | `inftwt` | `irsetting`, `infilChart` | 침입수 분석 | API/백엔드 계약은 `inftwt`, 기존 route `irsetting`과 차트명 `infilChart`는 별칭 | 혼재 | +| 수용가 | `rcptr` | 없음 | 수용가정보 | API/BE 계약 기준 `rcptr` 사용 | 기준 확정 | + +## 6. 신규 작업에서 사용할 기준 +신규 코드에서는 아래 이름을 우선 사용한다. + +### 6.1 사업/운영 +- `dcrpPpl` +- `sbmrgPrevnt` +- `bizInfo` + +### 6.2 지도/시설 +- `swagePpl` +- `swageMhl` +- `swagePmstn` +- `stofcbr` +- `swtrmtFclt` +- `swtrmtZone` +- `waterDepth` +- `lyrStng` + +### 6.3 분석 +- `inftwt` +- `rdii` +- `rcptr` + +## 7. 신규 작업에서 피할 별칭 +아래 이름은 기존 코드 해석용으로만 두고, 신규 파일/함수/변수명으로는 우선 사용하지 않는다. + +- `oldPp`를 API/DTO 이름으로 사용하는 것 +- `floodPre`를 API 계약 이름으로 사용하는 것 +- `sewagePump`를 신규 화면/컴포넌트 기준 이름으로 사용하는 것 +- `swagePrcsFclt`, `swagePrcsZone`를 신규 API/DTO 기준 이름으로 사용하는 것 +- `irsetting`을 신규 기능명으로 사용하는 것 +- `infilChart`를 침입수 분석 전체 기능명으로 사용하는 것 + +## 8. 확인 필요 항목 +- `oldPp` route를 장기적으로 `dcrpPpl` 또는 한글 route 명으로 정리할지 +- `swagePmstn`와 `sewagePump` 중 화면 컴포넌트 기준 이름을 통일할지 +- `swtrmtFclt`와 `swagePrcsFclt`를 같은 개념으로 볼지 분리할지 +- `swtrmtZone`와 `swagePrcsZone`를 같은 개념으로 볼지 분리할지 +- `irsetting` route를 `inftwt` 의미에 맞춰 정리할지 + +## 9. 작업카드 반영 규칙 +신규 화면/컴포넌트/API 작업 시 아래를 반드시 기록한다. +- 화면 라벨 +- 라우트명 +- API 필드명 +- 프론트 변수명 +- 기준 용어 +- 기존 별칭 사용 여부 +- 용어사전 갱신 여부 + diff --git a/apps/sn/docs/기준문서/011-환경빌드문제대응.md b/apps/sn/docs/기준문서/011-환경빌드문제대응.md new file mode 100644 index 000000000..4f7f39cc2 --- /dev/null +++ b/apps/sn/docs/기준문서/011-환경빌드문제대응.md @@ -0,0 +1,23 @@ +# 011. 환경빌드문제대응 + +## 1. 공통 기준 +- 공통 환경/빌드 이슈는 `COMMON_DOCS_ROOT/기준문서/004-환경빌드문제대응.md`를 함께 본다. + +## 2. 프론트 기본 점검 +- `pnpm -v`, `node -v` 버전을 먼저 확인한다. +- 작업 루트는 기본적으로 `FE_REPO_ROOT/`를 사용한다. +- 의존성 문제 의심 시 `pnpm install` 상태와 lockfile 변경 여부를 함께 본다. + +## 3. 기본 빌드 명령 +- `pnpm --filter sn run build` +- `pnpm turbo run build --filter=sn` +- `pnpm --filter @common/business_components run build` + +## 4. 점검 순서 +- 에디터 타입 오류인지 실제 빌드 오류인지 먼저 구분한다. +- `sn`만의 오류인지 공통 패키지 영향인지 구분한다. +- 한글 경로, 캐시, Node 버전, pnpm workspace 인식 문제를 순서대로 확인한다. + +## 5. 대응 원칙 +- 원인 확인 전 광범위한 삭제나 재설치는 마지막 수단으로 둔다. +- 공통 패키지 문제면 `sn` 영향 범위를 함께 기록한다. diff --git a/apps/sn/docs/기준문서/012-코드리뷰체크리스트.md b/apps/sn/docs/기준문서/012-코드리뷰체크리스트.md new file mode 100644 index 000000000..19e00b921 --- /dev/null +++ b/apps/sn/docs/기준문서/012-코드리뷰체크리스트.md @@ -0,0 +1,29 @@ +# 012. 코드리뷰체크리스트 + +## 1. 화면 동작 +- 조회, 상세, 다운로드, 팝업 흐름이 요구사항대로 동작하는가 +- 로딩/빈 화면/에러 상태가 빠지지 않았는가 +- 기존 화면 동작을 깨는 회귀가 없는가 + +## 2. API 연동 +- 실제 백엔드 요청 파라미터와 응답 필드를 기준으로 구현했는가 +- 신규 또는 변경 API URL이 백엔드 lowerCamelCase 기준과 일치하는가 +- 에러 응답 또는 예외 케이스를 화면에서 처리하는가 +- 프론트 임의 가공 로직이 계약 불일치를 숨기고 있지 않은가 + +## 3. 범위 준수 +- 수정 범위가 `apps/sn/**` 또는 `packages/common/business_components/**` 안에 머무르는가 +- 다른 공통 패키지 수정이 필요하면 원인과 영향 범위를 먼저 정리했는가 + +## 4. 코드 품질 +- 컴포넌트 책임이 과도하게 섞이지 않았는가 +- 상태, 이벤트, 비동기 호출이 읽기 쉽게 분리되어 있는가 +- 서버 조회 결과를 store에 둘 때 로딩/오류/중복 호출 방지/재조회 기준이 있는가 +- 같은 기준 데이터를 관리하는 store가 중복되어 있지 않은가 +- React Query를 쓰는 경우 QueryClient가 안정적으로 유지되는가 +- 이름이 역할을 설명하고 있는가 + +## 5. 검증 +- 빌드 명령 또는 필요한 로컬 검증을 수행했는가 +- 브라우저에서 핵심 시나리오를 확인했는가 +- 변경 이유, 영향 범위, 남은 리스크를 결과 보고에 남겼는가 diff --git a/apps/sn/docs/기준문서/013-UI레이아웃가이드.md b/apps/sn/docs/기준문서/013-UI레이아웃가이드.md new file mode 100644 index 000000000..5bfb9c941 --- /dev/null +++ b/apps/sn/docs/기준문서/013-UI레이아웃가이드.md @@ -0,0 +1,90 @@ +# 013. UI레이아웃가이드 + +## 1. 목적 +- `sn` 앱 화면 작업 시 기존 하수관로시스템 UI 패턴을 그대로 따르기 위한 구현 기준을 정리한다. +- 이후 기능 추가나 화면 개선 시 “무엇을 재사용해야 하는지”를 빠르게 판단할 수 있게 한다. + +## 2. 이번 분석에서 확인한 기준 화면 +- 업무형 조회/등록 화면 + - `apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx` + - `apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx` + - `apps/sn/app/(with-base-layout)/ii/rdii/page.tsx` +- 지도형 화면 + - `apps/sn/app/(map-layout)/map/page.tsx` +- 공통 레이아웃/구성 + - `packages/common/components/src/ui/layout/base.tsx` + - `packages/common/components/src/ui/layout/nofooter.tsx` + - `apps/sn/src/shared/ui/page-layout.tsx` + - `packages/common/components/src/ui/layout/common.css` +- 공통 비즈니스 컴포넌트 + - `packages/common/business_components/src/ui/search_form/index.tsx` + - `packages/common/business_components/src/ui/search_form_left/index.tsx` + - `packages/common/business_components/src/ui/search_form_right/index.tsx` + - `packages/common/business_components/src/ui/list_div/index.tsx` + - `packages/common/business_components/src/ui/table/index.tsx` + - `packages/common/business_components/src/ui/map/Gis.js` + +## 3. 레이아웃 선택 기준 + +### 3.1 업무형 화면 +- 대상: 등록현황, 분석 조건 입력, 조회/목록/업로드/모달 중심 화면 +- 기본 구조: + - `BaseLayout` + - `PageLayout` + - 상단 검색영역: `SearchDiv` + - 좌측 검색조건: `SearchFormLeft` + - 우측 액션 버튼: `SearchFormRight` + - 본문 카드: `ListDiv` + - 목록: `UiTable` + - 팝업: `UiModal` + +### 3.2 지도형 화면 +- 대상: 레이어 트리, 피처 선택, 상세 팝업, 검색 모달, 지도 도구 중심 화면 +- 기본 구조: + - `NoFooterLayout` + - `Gis` + - `ProcSearchBar` 또는 지도 전용 검색 UI + - 우측 상세 팝업: 지도 피처 상세 컴포넌트 + - 레이어 트리/툴바/줌레벨: `Gis` 내부 패턴 유지 + +## 4. 시각 패턴 +- 기본 카드 스타일은 `common.css`의 `card`, `card-border`, `card-header`, `card-footer`를 우선 따른다. +- 글자 크기는 업무 화면에서 `text-2xl`, `text-xl` 계열을 많이 사용하므로, 신규 화면도 이 리듬을 우선 유지한다. +- 버튼은 화면마다 새 변형을 만들기보다 기존 `Button` 스타일과 색상 조합을 우선 사용한다. +- 리스트/테이블/검색영역은 유리질 카드나 대시보드형 실험적 스타일보다 현재 반투명 카드 + 경계선 + 표 구조를 우선 유지한다. + +## 5. 검색영역 규칙 +- 검색영역은 상단 1행 구조를 기본으로 한다. +- 왼쪽에는 조회 조건, 오른쪽에는 `조회`, `등록`, `다운로드` 같은 액션 버튼을 둔다. +- 검색 필드는 `Label + Input/DatePicker/Select` 조합을 유지한다. +- 새 화면도 가능하면 “등록기간/조건입력/조회버튼” 패턴을 크게 벗어나지 않는다. + +## 6. 목록/테이블 규칙 +- 데이터 목록은 `UiTable`을 우선 사용한다. +- 카드 헤더에는 제목과 총 건수, 우측 액션 버튼을 배치한다. +- 페이징, 선택 초기화, 셀 클릭 동작은 기존 `UiTable` 사용 패턴을 따른다. +- 새 테이블을 만들 때는 먼저 `headlist` 설정 파일로 컬럼 구조를 분리하는 방식을 검토한다. + +## 7. 모달/상세 규칙 +- 상세 보기, 등록, 엑셀 미리보기는 `UiModal` 패턴을 우선 사용한다. +- 지도형 화면 상세는 우측 고정 패널 또는 기존 지도 상세 팝업 위치를 우선 유지한다. +- 같은 기능에서 목록 선택 후 상세/모달이 열리는 흐름은 기존 `oldPp`, `floodPre`, `map` 패턴을 참고한다. + +## 8. 상태관리/API 연동 규칙 +- 화면 내부 로컬 상태로 충분한 것은 로컬 state를 우선 사용한다. +- 사용자/공통 코드/메뉴/검색 모달 상태는 기존 store 패턴을 우선 따른다. +- API 호출은 기존 `searchData`, `getData`, `postData`, `deleteData` 계열을 우선 사용한다. +- 프론트에서 백엔드 응답 구조를 임의 가정하지 않고, 현재 화면이 실제로 쓰는 필드 기준으로 맞춘다. + +## 9. 새 화면 설계 시 체크리스트 +- 이 화면이 업무형인지 지도형인지 먼저 구분했는가 +- 기존 레이아웃(`BaseLayout` / `NoFooterLayout`) 중 하나를 선택했는가 +- 검색영역을 `SearchDiv + SearchFormLeft/Right` 패턴으로 풀 수 있는가 +- 목록을 `UiTable`로 처리할 수 있는가 +- 상세/등록 흐름을 `UiModal` 또는 기존 상세 패널 패턴으로 맞출 수 있는가 +- 새 스타일을 만들기 전에 `@common/components`, `@common/business_components` 재사용 가능성을 확인했는가 + +## 10. 예외 처리 원칙 +- 기존 패턴으로 해결되지 않는 요구사항일 때만 새 UI 구조를 제안한다. +- 그 경우에도 “왜 기존 패턴으로 해결되지 않는지”를 먼저 설계 문서에 남긴다. +- 예외 구조를 만들 때는 이후 다른 화면에도 재사용 가능한지 같이 검토한다. diff --git a/apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md b/apps/sn/docs/작업계획/작업카드-템플릿.md similarity index 85% rename from apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md rename to apps/sn/docs/작업계획/작업카드-템플릿.md index 6e0417aa3..86cd29f5d 100644 --- a/apps/sn/docs/AI코딩하네스/작업계획/000-작업카드-템플릿.md +++ b/apps/sn/docs/작업계획/작업카드-템플릿.md @@ -10,6 +10,12 @@ - 담당 범위: - 근거 문서: +## 1-1. 공통 동기화 확인 +- 공통 작업현황판 확인 여부: +- 파일점유현황 확인 여부: +- 결정로그 확인 여부: +- 기존 진행 중 작업과의 관계: + ## 2. 작업 목적 - 확인 필요: @@ -31,6 +37,7 @@ - 충돌 주의 파일: - 인수인계 메모: +- 파일점유 등록 여부: ## 4. 화면 흐름 - 확인 필요: 진입 경로 @@ -57,6 +64,11 @@ - 병렬 진행 가능 항목: - 순차 진행 필요 항목: +## 6-2. 채팅방 간 충돌 방지 +- 같은 기능을 다른 채팅방에서 진행 중인지: +- 겹치는 파일 또는 폴더: +- 점유 충돌 시 처리 방식: + ## 7. 검증 방법 - 확인 필요: 빌드 명령 - 확인 필요: 브라우저 URL diff --git a/apps/sn/public/images/map/layericon/Layer_Select_BTN.png b/apps/sn/public/images/map/layericon/Layer_Select_BTN.png new file mode 100644 index 000000000..1e810feaf Binary files /dev/null and b/apps/sn/public/images/map/layericon/Layer_Select_BTN.png differ diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx index 692b55ef0..29d73b03f 100644 --- a/apps/sn/src/app/index.tsx +++ b/apps/sn/src/app/index.tsx @@ -1,7 +1,3 @@ -import "./styles/base.css"; -import "./styles/components.css"; -import "@fontsource-variable/noto-sans-kr"; - import { AppProvider } from "./providers"; import { cookies } from "next/headers"; diff --git a/apps/sn/src/app/styles/base.css b/apps/sn/src/app/styles/base.css index 8453ff9e8..6cc507bb9 100644 --- a/apps/sn/src/app/styles/base.css +++ b/apps/sn/src/app/styles/base.css @@ -10,3 +10,7 @@ body { font-family: "Noto Sans KR", sans-serif; font-size: 10px; } + +body.sn-map-page-lock { + overflow: hidden; +} diff --git a/apps/sn/src/entities/auth/lib/token-manager.ts b/apps/sn/src/entities/auth/lib/token-manager.ts index bae8ff421..5b24c8bd3 100644 --- a/apps/sn/src/entities/auth/lib/token-manager.ts +++ b/apps/sn/src/entities/auth/lib/token-manager.ts @@ -76,7 +76,7 @@ export class TokenManager { secure: window.location.protocol === "https:", sameSite: "lax", //domain: domain, - //path: "/", + path: "/", }); } diff --git a/apps/sn/src/features/windowPopup/index.ts b/apps/sn/src/features/windowPopup/index.ts new file mode 100644 index 000000000..8424dcc1d --- /dev/null +++ b/apps/sn/src/features/windowPopup/index.ts @@ -0,0 +1,14 @@ +export { openWindowPopup } from "./lib/openWindowPopup"; +export { + isWindowPopupKey, + windowPopupDefinitions, + type WindowPopupKey, +} from "./model/popupDefinitions"; +export { windowPopupRegistry } from "./registry"; +export type { + OpenWindowPopupParams, + WindowPopupContentProps, + WindowPopupMessageType, + WindowPopupSearchParams, +} from "./model/types"; +export { WINDOW_POPUP_MESSAGE_TYPE } from "./model/types"; diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts new file mode 100644 index 000000000..dbb90794a --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -0,0 +1,61 @@ +import { + isWindowPopupKey, + windowPopupDefinitions, + type WindowPopupKey, +} from "../model/popupDefinitions"; +import type { OpenWindowPopupParams } from "../model/types"; + +const buildQueryString = (params: OpenWindowPopupParams = {}) => { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") return; + searchParams.set(key, String(value)); + }); + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ""; +}; + +const buildPopupFeatures = (width: number, height: number) => { + const screenLeft = window.screenX ?? window.screenLeft ?? 0; + const screenTop = window.screenY ?? window.screenTop ?? 0; + const outerWidth = window.outerWidth || window.screen.width; + const outerHeight = window.outerHeight || window.screen.height; + const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2)); + const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2)); + + return [ + `width=${width}`, + `height=${height}`, + `left=${left}`, + `top=${top}`, + "resizable=yes", + "scrollbars=yes", + "status=no", + "location=no", + "menubar=no", + "toolbar=no", + ].join(","); +}; + +export const openWindowPopup = ( + popupKey: WindowPopupKey, + params?: OpenWindowPopupParams, +) => { + if (typeof window === "undefined") return null; + if (!isWindowPopupKey(popupKey)) return null; + + const definition = windowPopupDefinitions[popupKey]; + const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; + const popupFeatures = buildPopupFeatures(definition.width, definition.height); + const popupWindow = window.open( + popupUrl, + `sn-popup-${popupKey}`, + popupFeatures, + ); + + popupWindow?.focus(); + + return popupWindow; +}; diff --git a/apps/sn/src/features/windowPopup/lib/popupMessage.ts b/apps/sn/src/features/windowPopup/lib/popupMessage.ts new file mode 100644 index 000000000..6ae7ad7f9 --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/popupMessage.ts @@ -0,0 +1,24 @@ +import type { WindowPopupMessageType } from "../model/types"; + +export const postPopupMessage = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + if (typeof window === "undefined" || !window.opener) return; + + window.opener.postMessage( + { + type, + payload, + }, + window.location.origin, + ); +}; + +export const postPopupMessageAndClose = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + postPopupMessage(type, payload); + window.close(); +}; diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts new file mode 100644 index 000000000..fe2ee66ba --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -0,0 +1,124 @@ +import type { WindowPopupDefinition } from "./types"; + +export const windowPopupDefinitions = { + oldPpUpload: { + key: "oldPpUpload", + title: "노후 하수관로 정비사업 등록", + width: 860, + height: 360, + }, + oldPpTable: { + key: "oldPpTable", + title: "노후 하수관로 정비사업 상세", + width: 1400, + height: 900, + requiredParams: ["fileNm", "atflId"], + }, + floodPreUpload: { + key: "floodPreUpload", + title: "도시침수 예방사업 등록", + width: 860, + height: 360, + }, + floodPreTable: { + key: "floodPreTable", + title: "도시침수 예방사업 상세", + width: 1400, + height: 900, + requiredParams: ["fileNm", "atflId"], + }, + reportTab: { + key: "reportTab", + title: "하수도정비 기본계획보고서", + width: 1280, + height: 920, + }, + reportFiles: { + key: "reportFiles", + title: "보고서 파일 목록", + width: 860, + height: 720, + requiredParams: ["clsfSystmSn"], + }, + mapSearch: { + key: "mapSearch", + title: "관망도 검색", + width: 1280, + height: 980, + }, + infilChart: { + key: "infilChart", + title: "침입수 분석", + width: 1360, + height: 980, + requiredParams: ["pplMngNo"], + }, + rdiiChart: { + key: "rdiiChart", + title: "RDII 분석", + width: 1360, + height: 980, + requiredParams: ["pplMngNo"], + }, + sscDetail: { + key: "sscDetail", + title: "중점우수토실 운영정보", + width: 1180, + height: 980, + requiredParams: ["chmbrXcnts", "chmbrYdnts"], + }, + agedPipePopup: { + key: "agedPipePopup", + title: "노후 하수관로 관리 ", + width: 1180, + height: 760, + }, + pipeAnalysisPopup: { + key: "pipeAnalysisPopup", + title: "관로분석", + width: 1180, + height: 820, + }, + pipeAnalysisDetailPopup: { + key: "pipeAnalysisDetailPopup", + title: "관로현황", + width: 1180, + height: 760, + }, + floodPredictionMapPopup: { + key: "floodPredictionMapPopup", + title: "침수예측지도", + width: 1080, + height: 760, + }, + floodHistoryPopup: { + key: "floodHistoryPopup", + title: "침수발생이력", + width: 1080, + height: 760, + }, + gisEditPopup: { + key: "gisEditPopup", + title: "GIS 편집", + width: 1080, + height: 760, + }, + gisManagementPopup: { + key: "gisManagementPopup", + title: "GIS 관리", + width: 1080, + height: 760, + }, + flowRiskPopup: { + key: "flowRiskPopup", + title: "통수 위험", + width: 1080, + height: 760, + }, +} as const satisfies Record; + +export type WindowPopupKey = keyof typeof windowPopupDefinitions; + +export const isWindowPopupKey = (key: string): key is WindowPopupKey => { + return key in windowPopupDefinitions; +}; diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts new file mode 100644 index 000000000..8387e03c1 --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -0,0 +1,37 @@ +import type { ComponentType } from "react"; + +export type WindowPopupSearchParams = Record< + string, + string | string[] | undefined +>; + +export type WindowPopupContentProps = { + searchParams: WindowPopupSearchParams; +}; + +export type WindowPopupDefinition = { + key: string; + title: string; + width: number; + height: number; + requiredParams?: string[]; +}; + +export type WindowPopupRegistryItem = WindowPopupDefinition & { + component: ComponentType; +}; + +export type OpenWindowPopupParams = Record< + string, + string | number | boolean | null | undefined +>; + +export const WINDOW_POPUP_MESSAGE_TYPE = { + OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted", + FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", + MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", + MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple", +} as const; + +export type WindowPopupMessageType = + (typeof WINDOW_POPUP_MESSAGE_TYPE)[keyof typeof WINDOW_POPUP_MESSAGE_TYPE]; diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx new file mode 100644 index 000000000..c76d9064f --- /dev/null +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -0,0 +1,118 @@ +import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions"; +import type { WindowPopupRegistryItem } from "./model/types"; +import AgedPipePopupContent from "./ui/AgedPipePopupContent"; +import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent"; +import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent"; +import InfilChartPopupContent from "./ui/InfilChartPopupContent"; +import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent"; +import OldPpTablePopupContent from "./ui/OldPpTablePopupContent"; +import RdiiChartPopupContent from "./ui/RdiiChartPopupContent"; +import ReportFilesPopupContent from "./ui/ReportFilesPopupContent"; +import ReportTabPopupContent from "./ui/ReportTabPopupContent"; +import SearchModalPopupContent from "./ui/SearchModalPopupContent"; +import SscDetailPopupContent from "./ui/SscDetailPopupContent"; +import type { WindowPopupContentProps } from "./model/types"; +import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; +import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; +import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; + +const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => ( + +); + +const FloodHistoryPopupContent = (_props: WindowPopupContentProps) => ( + +); + +const GisEditPopupContent = (_props: WindowPopupContentProps) => ( + +); + +const GisManagementPopupContent = (_props: WindowPopupContentProps) => ( + +); + +const FlowRiskPopupContent = (_props: WindowPopupContentProps) => ( + +); + +export const windowPopupRegistry = { + oldPpUpload: { + ...windowPopupDefinitions.oldPpUpload, + component: OldPpUploadPopupContent, + }, + oldPpTable: { + ...windowPopupDefinitions.oldPpTable, + component: OldPpTablePopupContent, + }, + floodPreUpload: { + ...windowPopupDefinitions.floodPreUpload, + component: FloodPreUploadPopupContent, + }, + floodPreTable: { + ...windowPopupDefinitions.floodPreTable, + component: FloodPreTablePopupContent, + }, + reportTab: { + ...windowPopupDefinitions.reportTab, + component: ReportTabPopupContent, + }, + reportFiles: { + ...windowPopupDefinitions.reportFiles, + component: ReportFilesPopupContent, + }, + mapSearch: { + ...windowPopupDefinitions.mapSearch, + component: SearchModalPopupContent, + }, + infilChart: { + ...windowPopupDefinitions.infilChart, + component: InfilChartPopupContent, + }, + rdiiChart: { + ...windowPopupDefinitions.rdiiChart, + component: RdiiChartPopupContent, + }, + sscDetail: { + ...windowPopupDefinitions.sscDetail, + component: SscDetailPopupContent, + }, + agedPipePopup: { + ...windowPopupDefinitions.agedPipePopup, + component: AgedPipePopupContent, + }, + pipeAnalysisPopup: { + ...windowPopupDefinitions.pipeAnalysisPopup, + component: PipeAnalysisPopupContent, + }, + pipeAnalysisDetailPopup: { + ...windowPopupDefinitions.pipeAnalysisDetailPopup, + component: PipeAnalysisDetailPopupContent, + }, + floodPredictionMapPopup: { + ...windowPopupDefinitions.floodPredictionMapPopup, + component: FloodPredictionMapPopupContent, + }, + floodHistoryPopup: { + ...windowPopupDefinitions.floodHistoryPopup, + component: FloodHistoryPopupContent, + }, + gisEditPopup: { + ...windowPopupDefinitions.gisEditPopup, + component: GisEditPopupContent, + }, + gisManagementPopup: { + ...windowPopupDefinitions.gisManagementPopup, + component: GisManagementPopupContent, + }, + flowRiskPopup: { + ...windowPopupDefinitions.flowRiskPopup, + component: FlowRiskPopupContent, + }, +} as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx new file mode 100644 index 000000000..9ff897803 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import AgedPipePopupPage from "@/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage"; + +export default function AgedPipePopupContent() { + return ( + + + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx new file mode 100644 index 000000000..e05b3b6b5 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx @@ -0,0 +1,25 @@ +type Props = { + title: string; +}; + +export default function FeaturePlaceholderPopupContent({ title }: Props) { + return ( +
+
+

{title}

+
+ +
+
+

{title}

+

+ 현재는 팝업 창 구조만 우선 연결했습니다. +

+

+ 상세 기능은 추후 단계에서 구현될 예정입니다. +

+
+
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx new file mode 100644 index 000000000..bbc342b70 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx @@ -0,0 +1,36 @@ +import TableModal from "@/app/(with-base-layout)/oper/floodPre/tableModal"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function FloodPreTablePopupContent({ + searchParams, +}: WindowPopupContentProps) { + const fileNm = getPopupParamValue(searchParams.fileNm); + const atflId = getPopupParamValue(searchParams.atflId); + const missingParams = [ + !fileNm ? "fileNm" : "", + !atflId ? "atflId" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.floodPreTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx new file mode 100644 index 000000000..629c6be53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { postData } from "@/components/lib/api"; +import OperFileUploadForm from "@/components/ui/operFileUploadForm"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { AUTH_USER_ID } from "@/shared/config"; +import { postPopupMessageAndClose } from "../lib/popupMessage"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; + +export default function FloodPreUploadPopupContent() { + const handleSubmit = async ( + fileNm: string, + year: string, + quater: string, + cd: string, + id: string, + ) => { + const data = await postData("v1/sbmrgPrevnt", { + mngYr: year, + mngQu: quater, + atflId: id, + atflCd: cd, + rgtr: tokenManager.getToken(AUTH_USER_ID) || null, + sbmrgPrevntBizNm: fileNm, + }); + + if (data) { + alert("파일이 등록되었습니다."); + postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx new file mode 100644 index 000000000..f6a739f53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx @@ -0,0 +1,22 @@ +import InfilChart from "@/app/(map-layout)/map/components/popupComponent/chart/infilChart"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function InfilChartPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + + if (!pplMngNo) { + return ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx new file mode 100644 index 000000000..3d3439d9c --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx @@ -0,0 +1,36 @@ +import TableModal from "@/app/(with-base-layout)/oper/oldPp/tableModal"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function OldPpTablePopupContent({ + searchParams, +}: WindowPopupContentProps) { + const fileNm = getPopupParamValue(searchParams.fileNm); + const atflId = getPopupParamValue(searchParams.atflId); + const missingParams = [ + !fileNm ? "fileNm" : "", + !atflId ? "atflId" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.oldPpTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx new file mode 100644 index 000000000..e8e1b1e52 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { postData } from "@/components/lib/api"; +import OperFileUploadForm from "@/components/ui/operFileUploadForm"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { AUTH_USER_ID } from "@/shared/config"; +import { postPopupMessageAndClose } from "../lib/popupMessage"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; + +export default function OldPpUploadPopupContent() { + const handleSubmit = async ( + fileNm: string, + year: string, + quater: string, + cd: string, + id: string, + ) => { + const data = await postData("v1/dcrpPpl", { + mngYr: year, + mngQu: quater, + atflCd: cd, + atflId: id, + rgtr: tokenManager.getToken(AUTH_USER_ID) || null, + dcrpPplMtncBizNm: fileNm, + }); + + if (data) { + alert("파일이 등록되었습니다."); + postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx new file mode 100644 index 000000000..c7d6f1f2a --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import PipeAnalysisDetailPopupPage from "@/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage"; + +export default function PipeAnalysisDetailPopupContent() { + return ( + + + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx new file mode 100644 index 000000000..215b559a9 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import PipeAnalysisPopupPage from "@/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage"; + +export default function PipeAnalysisPopupContent() { + return ( + + + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx new file mode 100644 index 000000000..32b8bb088 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx @@ -0,0 +1,19 @@ +export default function PopupMissingParams({ + title, + missingParams, +}: { + title: string; + missingParams: string[]; +}) { + return ( +
+

{title}

+

+ 팝업을 여는 데 필요한 값이 누락되었습니다. +

+

+ 누락된 값: {missingParams.join(", ")} +

+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx new file mode 100644 index 000000000..8a591e004 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx @@ -0,0 +1,22 @@ +import RdiiChart from "@/app/(map-layout)/map/components/popupComponent/chart/rdiiChart"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function RdiiChartPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + + if (!pplMngNo) { + return ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx new file mode 100644 index 000000000..de3064383 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { getDcData } from "@/components/lib/api"; +import { UiTable } from "@common/business_components/ui"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +type ReportDataFile = { + no?: number; + fileTitle: string; + regDt: string; + fileSn: string; + filePath: string; +}; + +const REPORT_FILES_HEADLIST = [ + { id: "no", title: "NO" }, + { id: "fileTitle", title: "파일명" }, + { id: "regDt", title: "등록일자" }, +]; + +export default function ReportFilesPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const clsfSystmSn = getPopupParamValue(searchParams.clsfSystmSn); + const [files, setFiles] = useState([]); + const [allFiles, setAllFiles] = useState([]); + const [curPage, setCurPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!clsfSystmSn) return; + + const fetchFiles = async () => { + setIsLoading(true); + try { + const result = await getDcData(`/api/v1/file?clsfSystmSn=${clsfSystmSn}`); + const normalized = (result ?? []).map((item: ReportDataFile, index: number) => ({ + ...item, + no: index + 1, + })); + setAllFiles(normalized); + } catch (error) { + console.error("보고서 파일 목록 조회 실패:", error); + setAllFiles([]); + } finally { + setIsLoading(false); + } + }; + + fetchFiles(); + }, [clsfSystmSn]); + + useEffect(() => { + const paged = allFiles.slice((curPage - 1) * 10, curPage * 10); + setFiles(paged); + }, [allFiles, curPage]); + + const handleDownload = async (fileSn: string, fileTitle: string, filePath: string) => { + if (!filePath) { + alert("filePath 정보가 없으므로 다운로드가 불가합니다."); + return; + } + + try { + const responseBlob = await getDcData( + `/api/v1/fileDownload?filePath=${encodeURIComponent(filePath)}`, + "blob", + ); + + const objectUrl = URL.createObjectURL(responseBlob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = fileTitle; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); + } catch (error) { + console.error("보고서 파일 다운로드 실패:", error); + alert("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + if (!clsfSystmSn) { + return ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.reportFiles.title} +

+
+ + { + if (typeof index !== "number" || !files[index]) return; + const target = files[index]; + handleDownload(target.fileSn, target.fileTitle, target.filePath); + }} + /> +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx new file mode 100644 index 000000000..d2913df8e --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx @@ -0,0 +1,27 @@ +import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function ReportTabPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const ctpvNm = getPopupParamValue(searchParams.ctpvNm); + const sggNm = getPopupParamValue(searchParams.sggNm); + + return ( +
+

+ {windowPopupDefinitions.reportTab.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx new file mode 100644 index 000000000..0bbb50bbe --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx @@ -0,0 +1,5 @@ +import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal"; + +export default function SearchModalPopupContent() { + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx new file mode 100644 index 000000000..9e0ff6464 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx @@ -0,0 +1,36 @@ +import Ssc from "@/app/(map-layout)/map/components/popupComponent/Ssc"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; +import type { WindowPopupContentProps } from "../model/types"; +import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popupContentUtils"; + +export default function SscDetailPopupContent({ + searchParams, +}: WindowPopupContentProps) { + const chmbrXcnts = getPopupParamValue(searchParams.chmbrXcnts); + const chmbrYdnts = getPopupParamValue(searchParams.chmbrYdnts); + + const missingParams = [ + !chmbrXcnts ? "chmbrXcnts" : "", + !chmbrYdnts ? "chmbrYdnts" : "", + ].filter(Boolean); + + if (missingParams.length > 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts new file mode 100644 index 000000000..5b2c1152f --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts @@ -0,0 +1,6 @@ +export const getPopupParamValue = ( + value: string | string[] | undefined, +): string => { + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +}; diff --git a/apps/wr/public/sample_rswtStats.xlsx b/apps/wr/public/sample_rswtStats.xlsx index c3885eca4..82dad8d2b 100644 Binary files a/apps/wr/public/sample_rswtStats.xlsx and b/apps/wr/public/sample_rswtStats.xlsx differ diff --git a/apps/wr/public/sample_rswtStats_물사용량.xlsx b/apps/wr/public/sample_rswtStats_물사용량.xlsx deleted file mode 100644 index 68cae66ee..000000000 Binary files a/apps/wr/public/sample_rswtStats_물사용량.xlsx and /dev/null differ diff --git a/packages/common/business_components/src/ui/excel/excelStyle.tsx b/packages/common/business_components/src/ui/excel/excelStyle.tsx index 221e1387c..1a654775c 100644 --- a/packages/common/business_components/src/ui/excel/excelStyle.tsx +++ b/packages/common/business_components/src/ui/excel/excelStyle.tsx @@ -70,7 +70,8 @@ export const excelStyleWithKoreanHeaders = async ( range: string = "A1:V3", fileNm: string, headerMapping: { [key: string]: string }, - sampleData: any + sampleData: any, + mergeRanges?: string[] ) => { const workbook = await XlsxPopulate.Workbook.fromData(data); const sheet = workbook.activeSheet(); @@ -83,7 +84,7 @@ export const excelStyleWithKoreanHeaders = async ( const englishHeaders = Object.keys(sampleData); englishHeaders.forEach((englishHeader, index) => { const koreanHeader = headerMapping[englishHeader] || englishHeader; - const cellAddress = String.fromCharCode(65 + index) + '1'; // A1, B1, C1, ... + const cellAddress = `${getExcelColumnLetter(index + 1)}1`; sheet.cell(cellAddress).value(koreanHeader); }); @@ -98,13 +99,20 @@ export const excelStyleWithKoreanHeaders = async ( const numRows = sheet.usedRange()?.endCell()?.rowNumber() || 1; if (numRows > 1) { - const dataRange = sheet.range(`A1:${String.fromCharCode(65 + numCols - 1)}${numRows}`); + const lastColLetter = getExcelColumnLetter(numCols); + const dataRange = sheet.range(`A1:${lastColLetter}${numRows}`); dataRange?.style((styleQ) => { styleQ.verticalAlignment("center"); styleQ.borderEach({ style: "thin", color: { rgb: "CCCCCC" } }); }); } + if (mergeRanges && mergeRanges.length > 0) { + mergeRanges.forEach((mergeRange) => { + sheet.range(mergeRange).merged(true); + }); + } + // 4. 파일 다운로드 await makeExcelFile(workbook, fileNm); }; diff --git a/packages/common/business_components/src/ui/excel/index.tsx b/packages/common/business_components/src/ui/excel/index.tsx index 54e4fe6a8..ef753f976 100644 --- a/packages/common/business_components/src/ui/excel/index.tsx +++ b/packages/common/business_components/src/ui/excel/index.tsx @@ -407,6 +407,7 @@ type ExcelDownloadWithHeaderProps = { sheetNameArray: string[]; headerRange?: string; headerMapping: { [key: string]: string }; + mergeRanges?: string[]; } export const downloadExcelWithKoreanHeaders = async ({ @@ -414,7 +415,8 @@ export const downloadExcelWithKoreanHeaders = async ({ fileName = new Date().getTime().toString() + '.xlsx', sheetNameArray = ["Sheet1"], headerRange = 'A1:V3', - headerMapping + headerMapping, + mergeRanges, }: ExcelDownloadWithHeaderProps) => { const dataArray = Array.isArray(data) ? data : [data]; @@ -432,5 +434,5 @@ export const downloadExcelWithKoreanHeaders = async ({ const buffer = XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }); // 3. 헤더만 한글로 교체하는 커스텀 스타일 함수 호출 - await excelStyleWithKoreanHeaders(buffer, headerRange, fileName, headerMapping, dataArray[0]); + await excelStyleWithKoreanHeaders(buffer, headerRange, fileName, headerMapping, dataArray[0], mergeRanges); }; diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 249c94a13..cf6411dd4 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -32,6 +32,9 @@ export const GisContext = createContext({ }); export const useGisContext = () => useContext(GisContext); +/** + * @param {any} props + */ function Gis({ isOpen, //우측 상세 레이어 팝업 gisMaps, @@ -49,16 +52,23 @@ function Gis({ setIsSearchModalOpen, onCheckedLayersChange, searchBar: SearchBar, - waterDepth: WaterDepth, + waterDepth: WaterDepth = null, + layerSettingView: LayerSettingView = null, + inlineLayerSettingView, + layerIconVisible, isPlaying = false, setIsPlaying = () => { }, targetLayerTypes = [], checkInterval = 3000, + // 체크 변경 콜백 지연 시간. 기본값은 기존 동작 유지, sn 유하방향처럼 즉시 반응이 필요한 화면은 0으로 전달한다. + checkedLayerChangeDelay = 1000, height: height = "h-[calc(100vh-5rem)]", onOpenPopup = (data) => { }, droughtDates = { startYmd: '', endYmd: '' }, defaultZoom = 15, defaultLonLat = [127.4875, 37.4916], + viewExtent, + viewMinZoom, showZoomLevel = false, }) { const mapRef = useRef(); @@ -137,8 +147,9 @@ function Gis({ GisApp.FROM_PROJ, GisApp.TO_PROJ, ), - minZoom: gisCore.defaults.minZoom, + minZoom: viewMinZoom ?? gisCore.defaults.minZoom, maxZoom: gisCore.defaults.maxZoom, + extent: viewExtent, }), }); } else { @@ -245,13 +256,17 @@ function Gis({ // 체크된 레이어 정보를 처리하는 함수 const handleCheckedLayersChange = useCallback( (checkedIds, checkedNodesData) => { - setTimeout(() => { + // 콜백 지연 옵션을 한 곳에서 처리해 기존 화면과 즉시 반응 화면의 동작 차이를 prop으로 제어한다. + const applyCheckedLayersChange = () => { setCheckedLayers(checkedIds); setCheckedLayersData(checkedNodesData); onCheckedLayersChange?.(checkedIds, checkedNodesData); - }, 1000); + }; + + const delayMs = Math.max(0, Number(checkedLayerChangeDelay) || 0); + setTimeout(applyCheckedLayersChange, delayMs); }, - [onCheckedLayersChange], + [checkedLayerChangeDelay, onCheckedLayersChange], ); // 진행률 변경 핸들러 @@ -281,18 +296,21 @@ function Gis({ visible={tabVisible} setVisible={setTabVisible} onCheckedChange={handleCheckedLayersChange} - onLayerIconClick={(layerNm) => { + onLayerIconClick={(layerNm, node) => { layerNmRef.current = layerNm; setLayerNm(layerNm); - setIsOpen(true); - onOpenPopup(layerNm) + const shouldOpenDetail = onOpenPopup(layerNm, node) + if (shouldOpenDetail !== false) setIsOpen(true); }} imageVisible={imgVisble} + layerIconVisible={layerIconVisible} isPlaying={isPlaying} setIsPlaying={setIsPlaying} targetLayerTypes={targetLayerTypes} checkInterval={checkInterval} onProgressChange={handleProgressChange} + layerSettingView={LayerSettingView} + inlineLayerSettingView={inlineLayerSettingView} /> {typeof WaterDepth === "function" && ( @@ -334,7 +352,7 @@ function Gis({
{isSearch && ( -
+