diff --git a/apps/dm/AGENTS.md b/apps/dm/AGENTS.md new file mode 100644 index 000000000..7eefb1676 --- /dev/null +++ b/apps/dm/AGENTS.md @@ -0,0 +1,38 @@ +## Project +This repository contains a Next.js web application. + +Main technologies: +- Next.js +- TypeScript +- pnpm workspace +- turborepo + +이 저장소는 pnpm workspace + Turborepo 기반 monorepo이다. + +- 워크스페이스 범위는 `apps/*`, `apps/common/*`, `packages/*`, `packages/common/*` +- 루트 스크립트는 `turbo run build`, `turbo run dev` 를 사용한다 +- `build` 작업은 의존 패키지의 `build` 를 먼저 수행한다 (`dependsOn: ["^build"]`) +- `dev` 는 캐시하지 않는 지속 실행 작업이다 +- 변경 범위만 선택 실행할 수 있으므로, 전체 빌드보다 `--filter` 사용을 우선한다 + +## Encoding +- UTF-8 +- 한글이 깨지지 않도록 주의할 것 + +## 주석 comment +- 한글 주석을 삭제하지 말 것 + +## Monorepo structure +apps/ + _template + as + cm + cs + dc + dm + dm_new + fo + sn + st + wr + diff --git a/apps/dm_new/AGENTS.md b/apps/dm_new/AGENTS.md new file mode 100644 index 000000000..7eefb1676 --- /dev/null +++ b/apps/dm_new/AGENTS.md @@ -0,0 +1,38 @@ +## Project +This repository contains a Next.js web application. + +Main technologies: +- Next.js +- TypeScript +- pnpm workspace +- turborepo + +이 저장소는 pnpm workspace + Turborepo 기반 monorepo이다. + +- 워크스페이스 범위는 `apps/*`, `apps/common/*`, `packages/*`, `packages/common/*` +- 루트 스크립트는 `turbo run build`, `turbo run dev` 를 사용한다 +- `build` 작업은 의존 패키지의 `build` 를 먼저 수행한다 (`dependsOn: ["^build"]`) +- `dev` 는 캐시하지 않는 지속 실행 작업이다 +- 변경 범위만 선택 실행할 수 있으므로, 전체 빌드보다 `--filter` 사용을 우선한다 + +## Encoding +- UTF-8 +- 한글이 깨지지 않도록 주의할 것 + +## 주석 comment +- 한글 주석을 삭제하지 말 것 + +## Monorepo structure +apps/ + _template + as + cm + cs + dc + dm + dm_new + fo + sn + st + wr + diff --git a/apps/dm_new/app/store/sdmMng/postcode-service.ts b/apps/dm_new/app/store/sdmMng/postcode-service.ts index e8a791adb..814d7e0c6 100644 --- a/apps/dm_new/app/store/sdmMng/postcode-service.ts +++ b/apps/dm_new/app/store/sdmMng/postcode-service.ts @@ -48,6 +48,16 @@ type SuggestionSeed = { bldgNoMno: number; }; +/** + * 주소 검색 API를 호출해 우편번호/주소 목록을 조회한다. + * @param searchQuery 조회 조건 객체 + * @param searchQuery.roadNm 도로명 + * @param searchQuery.bldgNoMno 건물 본번 + * @param searchQuery.bldgNoSno 건물 부번 + * @param searchQuery.page 0부터 시작하는 페이지 번호 + * @param searchQuery.size 페이지당 조회 건수 + * @returns 주소 목록과 전체 건수를 포함한 응답 객체 + */ async function findAddress(searchQuery: ZipSearchParams): Promise { if (!searchQuery) return {}; @@ -68,6 +78,12 @@ async function findAddress(searchQuery: ZipSearchParams): Promise { roadNm: "테헤란로", bldgNoMno: 152, bldgNoSno: 1, page } + * @param input 사용자가 입력한 주소 검색어 + * @param page 1부터 시작하는 요청 페이지 번호 + * @returns 파싱 성공 시 API 조회 파라미터와 정규화된 검색어, 실패 시 오류 메시지 + */ export function parsePostcodeInput( input: string, page = 1, @@ -205,6 +233,12 @@ export function parsePostcodeInput( }; } +/** + * 전체 도로명 주소 문자열에서 재검색에 사용할 "도로명 건물번호" 입력값만 추출한다. + * 예) "테헤란로 152, OO빌딩" -> "테헤란로 152" + * @param roadAddr 화면에 표시된 도로명 주소 문자열 + * @returns 재검색 입력란에 넣을 수 있는 문자열, 추출 실패 시 빈 문자열 + */ export function extractSearchInputFromRoadAddr(roadAddr: string): string { const firstSegment = roadAddr.split(",")[0]?.trim() ?? ""; if (!firstSegment) return ""; @@ -216,6 +250,12 @@ export function extractSearchInputFromRoadAddr(roadAddr: string): string { return `${parts.slice(0, -1).join(" ")} ${bldgToken}`.trim(); } +/** + * 사용자가 입력 중인 도로명을 기준으로 자동완성 후보를 조회한다. + * 건물번호가 함께 입력된 경우에는 마지막 숫자 토큰을 제외한 도로명만으로 후보를 찾는다. + * @param input 사용자가 입력창에 입력한 원본 문자열 + * @returns 최대 5개의 "도로명 건물본번" 자동완성 문자열 목록 + */ export async function fetchPostcodeSuggestions(input: string): Promise { const normalized = input.replace(/\s+/g, " ").trim(); const parts = normalized.split(" "); @@ -252,6 +292,15 @@ export async function fetchPostcodeSuggestions(input: string): Promise return suggestions; } +/** + * 우편번호 검색 API를 호출해 화면 표시용 우편번호 목록 형태로 변환한다. + * @param params 조회 조건 객체 + * @param params.roadNm 검색할 도로명 + * @param params.bldgNoMno 건물 본번 + * @param params.bldgNoSno 건물 부번 + * @param params.page 1부터 시작하는 화면 페이지 번호 + * @returns 전체 건수와 화면 표시용 주소 목록 + */ export async function fetchPostcode( params: PostcodeSearchParams, ): Promise { diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 7eefb1676..45f49f296 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -22,6 +22,11 @@ Main technologies: ## 주석 comment - 한글 주석을 삭제하지 말 것 +## html 디렉토리 +- apps/sn/app/html +- 이 디렉토리의 목적은 코드를 화면 publishing과 로직 설정을 위한 폴더이다. +- html 하위 *.md 요청 작업시 html 디렉토리 이외의 수정이 되지 않도록 해야 한다. + ## Monorepo structure apps/ _template diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx new file mode 100644 index 000000000..cf6db8572 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeDetailPanel.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Button } from "@common/components"; +import { + AGED_PIPE_DETAIL_FIELDS, + getElapsedYears, + getPipeDisplayValue, +} from "./agedPipeUtils"; + +type Props = { + featureData: any; + onClose: () => void; +}; + +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); + + return ( +
+
+
+
노후관로 상세
+
시설관리번호 {facilityNo}
+
+ 경과년도 {elapsedYears ?? "-"}년 +
+
+ +
+ +
+
+ {AGED_PIPE_DETAIL_FIELDS.map((field) => ( +
+
{field.title}
+
+ {String(getPipeDisplayValue(source, field.id) ?? "-")} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx new file mode 100644 index 000000000..5a94014a2 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeFilterPanel.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Button, Input } from "@common/components"; +import useStore from "@/store/useStore"; +import type { AgedPipeFilters } from "./agedPipeUtils"; + +type OverlayProps = { + onClose?: () => void; + isPlaying?: boolean; + setIsPlaying?: (value: boolean) => void; + totalSteps?: number; + currentStep?: number; +}; + +type Props = OverlayProps & { + filters: AgedPipeFilters; + onFiltersChange: (filters: AgedPipeFilters) => void; + onApply: () => void; +}; + +export default function AgedPipeFilterPanel({ + 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))); + + return ( +
+
+
노후관로 기준
+
+ 경과년수 이상 관로만 지도와 목록에 반영합니다. +
+
+ +
+ + + + + +
+ + +
+ ); +} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx new file mode 100644 index 000000000..bf6a8fb02 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/AgedPipeListModal.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useRef } from "react"; +import { Button, Input } from "@common/components"; +import { UiTable } from "@common/business_components/ui"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import useStore from "@/store/useStore"; +import { + AGED_PIPE_LIST_HEADLIST, + type AgedPipeFilters, + type AgedPipeRow, +} from "./agedPipeUtils"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + filters: AgedPipeFilters; + onFiltersChange: (filters: AgedPipeFilters) => void; + rows: AgedPipeRow[]; + total: number; + currentPage: number; + isLoading: boolean; + onSearch: (page?: number) => void; + onDownload: () => void; + onRowDoubleClick: (row: AgedPipeRow) => void; +}; + +export default function AgedPipeListModal({ + open, + onOpenChange, + filters, + onFiltersChange, + rows, + total, + currentPage, + isLoading, + onSearch, + onDownload, + onRowDoubleClick, +}: Props) { + const { ctpv, sgg } = useStore(); + const lastClickRef = useRef<{ index: number; clickedAt: number } | null>(null); + const sggOptions = filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))); + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + const now = Date.now(); + const lastClick = lastClickRef.current; + if (lastClick && lastClick.index === index && now - lastClick.clickedAt < 300) { + lastClickRef.current = null; + onRowDoubleClick(row); + return; + } + + lastClickRef.current = { index, clickedAt: now }; + }; + + if (!open) return null; + + return ( +
+
+ +
+
+
노후관로 목록
+
+ 지도 위에 띄워지는 윈도우형 팝업 미리보기 +
+
+ +
+ +
+
+ + + + + + +
+ + +
+
+ +
+ + headName={<>노후관로 조회 결과} + tableData={rows} + headlist={AGED_PIPE_LIST_HEADLIST} + pageSize={10} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => onSearch(page)} + curPage={currentPage} + widthFixed={true} + /> +
+ +
+ 목록 행을 더블클릭하면 지도 이동 후 상세 패널이 열립니다. +
+
+
+
+ ); +} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts b/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts new file mode 100644 index 000000000..baff4b003 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/agedPipeUtils.ts @@ -0,0 +1,221 @@ +"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, + MsSwagePplCol, +} from "../map/components/common/table"; + +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: 20, + 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: "경과년도", +}; + +export const AGED_PIPE_DETAIL_FIELDS = MsSwagePplCol; + +const getToday = () => new Date(); + +const pad2 = (value: number) => String(value).padStart(2, "0"); + +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())}`; +}; + +const normalizeYmd = (value: unknown) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); + +const getCodePrefix = (source: PipeSource) => + String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim(); + +const resolveRegionName = ( + code: string, + directName: unknown, + options: SelectDataType[], + prefixLength: number, +) => { + if (String(directName ?? "").trim()) return String(directName); + const prefix = code.slice(0, prefixLength); + return options.find((item) => String(item.val).trim() === prefix)?.text ?? ""; +}; + +export const getElapsedYears = (value: unknown) => { + const normalized = normalizeYmd(value); + if (normalized.length < 4) 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 buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { + const cqlFilters: string[] = []; + cqlFilters.push(`instl_ymd <= '${getCutoffYmd(filters.elapsedYears)}'`); + + if (filters.sggCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${filters.sggCd}%'`); + } else if (filters.ctpvCd !== "0") { + cqlFilters.push(`pbadms_emd_cd LIKE '${filters.ctpvCd}%'`); + } + + return cqlFilters; +}; + +export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => { + const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd); + if (!instlYmd || instlYmd > getCutoffYmd(filters.elapsedYears)) return false; + + const regionCode = getCodePrefix(source); + if (filters.sggCd !== "0") return regionCode.startsWith(filters.sggCd); + if (filters.ctpvCd !== "0") return regionCode.startsWith(filters.ctpvCd); + return true; +}; + +export const toDisplayPipeRow = ( + source: PipeSource, + index: number, + page: number, + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], +): AgedPipeRow => { + const formatted = formatDisplayData(source) as PipeSource; + const codePrefix = getCodePrefix(source); + const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd; + 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 ?? "-"; + + return { + ...source, + ...formatted, + fid: String(source.fid ?? ""), + lyrTypeNm: String(source.lyrTypeNm ?? "tb_ms_swage_ppl"), + rowNumber: (page - 1) * 10 + index + 1, + fcltMngNo: String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"), + instlYmd: 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 getPipeDisplayValue = (source: PipeSource, key: string) => { + const formatted = formatDisplayData(source) as PipeSource; + const keyMap: Record = { + fclt_mng_no: ["fclt_mng_no", "fcltMngNo"], + inst_mng_no: ["inst_mng_no", "instMngNo"], + instl_ymd: ["instl_ymd", "instlYmd"], + mapsht_no: ["mapsht_no", "mapshtNo"], + pdmt_vl: ["pdmt_vl", "pdmtVl"], + prlg_vl: ["prlg_vl", "prlgVl"], + usg_cd: ["usg_cd", "usgCd"], + mqlt_cd: ["mqlt_cd", "mqltCd"], + avg_grdn: ["avg_grdn", "avgGrdn"], + drtw_mltv_area: ["drtw_mltv_area", "drtwMltvArea"], + excln_mltv_area: ["excln_mltv_area", "exclnMltvArea"], + tmpt_dpth: ["tmpt_dpth", "tmptDpth"], + tmpt_mhl_mng_no: ["tmpt_mhl_mng_no", "tmptMhlMngNo"], + scend_dpth: ["scend_dpth", "scendDpth"], + scend_mhl_mng_no: ["scend_mhl_mng_no", "scendMhlMngNo"], + trfln_cnt: ["trfln_cnt", "trflnCnt"], + vrtc_len: ["vrtc_len", "vrtcLen"], + wdth_len: ["wdth_len", "wdthLen"], + }; + + const candidates = keyMap[key] ?? [key]; + for (const candidate of candidates) { + const value = formatted[candidate] ?? source[candidate]; + if (value !== undefined && value !== null && value !== "") { + if (candidate === "usg_cd" || candidate === "usgCd") { + return useMap.get(String(source.usg_cd ?? source.usgCd ?? "").trim()) ?? value; + } + if (candidate === "mqlt_cd" || candidate === "mqltCd") { + return materialMap.get(String(source.mqlt_cd ?? source.mqltCd ?? "").trim()) ?? value; + } + return value; + } + } + + return "-"; +}; diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx new file mode 100644 index 000000000..e3ebe4d3a --- /dev/null +++ b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Gis from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import Stroke from "ol/style/Stroke.js"; +import Style from "ol/style/Style.js"; +import useStore from "@/store/useStore"; +import { getData } from "@/components/lib/api"; +import AgedPipeFilterPanel from "./AgedPipeFilterPanel"; +import AgedPipeDetailPanel from "./AgedPipeDetailPanel"; +import AgedPipeListModal from "./AgedPipeListModal"; +import { + AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_EXCEL_HEADER, + buildAgedPipeCqlFilters, + matchesAgedPipeFilters, + toDisplayPipeRow, + type AgedPipeFilters, + type AgedPipeRow, +} from "./agedPipeUtils"; + +const agedPipeHighlightStyle = new Style({ + stroke: new Stroke({ + color: "rgba(239, 68, 68, 0.95)", + width: 5, + }), + zIndex: 999, +}); + +const PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; +const EmptySearchView = () => null; +const DESIGN_PREVIEW_FEATURE = { + fid: "preview-aged-pipe", + lyrTypeNm: PIPE_LAYER_TYPE, + fcltMngNo: "SN-PPL-000123", + instMngNo: "INST-4458", + instlYmd: "19981215", + mapshtNo: "M-12-34", + pdmtVl: 450, + prlgVl: 132.5, + usgCd: "합류관", + mqltCd: "철근콘크리트관(RC)", + avgGrdn: 0.0021, + drtwMltvArea: 2140, + exclnMltvArea: 980, + tmptDpth: 2.1, + tmptMhlMngNo: "TMH-2201", + scendDpth: 2.4, + scendMhlMngNo: "SMH-2202", + trflnCnt: 2, + vrtcLen: 1.2, + wdthLen: 1.2, +}; + +const Map = () => { + const gisMaps = useRef(null); + const [mapReady, setMapReady] = useState(false); + const [treeData, setTreeData] = useState([]); + const [isDetailOpen, setIsDetailOpen] = useState(true); + const [featureData, setFeatureData] = useState(DESIGN_PREVIEW_FEATURE); + const [draftFilters, setDraftFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); + const [isListOpen, setIsListOpen] = useState(true); + const [listRows, setListRows] = useState([ + { + fid: "preview-aged-pipe", + lyrTypeNm: PIPE_LAYER_TYPE, + rowNumber: 1, + fcltMngNo: "SN-PPL-000123", + instlYmd: "1998-12-15", + pdmtVl: 450, + prlgVl: 132.5, + usgCd: "합류관", + mqltCd: "철근콘크리트관(RC)", + elapsedYears: 27, + ctpvNm: "서울특별시", + sggNm: "강남구", + }, + { + fid: "preview-aged-pipe-2", + lyrTypeNm: PIPE_LAYER_TYPE, + rowNumber: 2, + fcltMngNo: "SN-PPL-000124", + instlYmd: "2001-03-10", + pdmtVl: 300, + prlgVl: 88.4, + usgCd: "오수관", + mqltCd: "염화비닐관(PVC)", + elapsedYears: 25, + ctpvNm: "서울특별시", + sggNm: "강남구", + }, + ]); + const [totalCount, setTotalCount] = useState(2); + const [currentPage, setCurrentPage] = useState(1); + const [isListLoading, setIsListLoading] = useState(false); + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); + const { ctpv, sgg } = useStore(); + + useEffect(() => { + const fetchData = async () => { + try { + let data; + const response = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", { + method: "GET", + }); + if (response.ok) { + const contentLength = response.headers.get("content-length"); + if (contentLength === "0") data = null; + + const text = await response.text(); + if (!text || text.trim() === "") data = null; + else data = JSON.parse(text); + } + + setTreeData(data); + } catch (error) { + console.error("노후관로 레이어 설정 조회 중 오류:", error); + throw new Error("Invalid JSON response from server"); + } + }; + + fetchData(); + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + if (gisMaps.current) { + setMapReady(true); + window.clearInterval(timer); + } + }, 300); + + return () => window.clearInterval(timer); + }, []); + + const fetchAgedPipeRows = useCallback( + async (page: number, filters: AgedPipeFilters, size: number) => { + const queryParams = new URLSearchParams({ + lyrTypeNm: PIPE_LAYER_TYPE, + page: String(page - 1), + size: String(size), + sort: "string", + }); + + const cqlFilters = buildAgedPipeCqlFilters(filters); + if (cqlFilters.length > 0) { + queryParams.append("cqlFiter", cqlFilters.join(" and ")); + } + + const response = await getData(`v1/fclts?${queryParams.toString()}`); + const rows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: rows.map((item: Record, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadAgedPipeList = useCallback( + async (page = 1, filters = appliedFilters) => { + setIsListLoading(true); + try { + const response = await fetchAgedPipeRows(page, filters, 10); + setListRows(response.rows); + setTotalCount(response.total); + setCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setListRows([]); + setTotalCount(0); + } finally { + setIsListLoading(false); + } + }, + [appliedFilters, fetchAgedPipeRows], + ); + + useEffect(() => { + loadAgedPipeList(1, appliedFilters); + }, [appliedFilters, loadAgedPipeList]); + + const handleApplyFilters = useCallback(() => { + const normalized = { + ...draftFilters, + elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20, + }; + setDraftFilters(normalized); + setAppliedFilters(normalized); + + if (isListOpen) { + loadAgedPipeList(1, normalized); + } + }, [draftFilters, isListOpen, loadAgedPipeList]); + + const handleFeatureSelect = useCallback((data: any) => { + const map = gisMaps.current; + if (!map) return; + + const select = GisApp.Module.select as any; + if (!select) return; + select.resetSelectedFeature(); + + let foundFeature = null; + if (data && typeof data.getGeometry === "function" && typeof data.setStyle === "function") { + foundFeature = data; + } else { + const featureId = data?.fid ?? data?.ftrIdn ?? data?.id; + map + .getLayers() + .getArray() + .forEach((layer: any) => { + const source = layer.getSource?.(); + if (!source?.getFeatureById) return; + const feature = source.getFeatureById(String(featureId).trim()); + if (feature) foundFeature = feature; + }); + } + + if (foundFeature?.getGeometry) { + map.getView().fit(foundFeature.getGeometry().getExtent(), { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + select.selectFeature(foundFeature, true); + } + + setFeatureData(data); + setIsDetailOpen(true); + }, []); + + const handleOpenPipeList = useCallback(() => { + setIsListOpen(true); + loadAgedPipeList(1, appliedFilters); + }, [appliedFilters, loadAgedPipeList]); + + const handleTreePopup = useCallback( + (layerNm: string) => { + if (String(layerNm).includes("하수관로")) { + handleOpenPipeList(); + } + }, + [handleOpenPipeList], + ); + + const handleListRowDoubleClick = useCallback( + async (row: AgedPipeRow) => { + try { + const core = GisApp.Module.layer as any; + await core.showSearchResultsFromGeoJSON(PIPE_LAYER_TYPE, row.fid); + } catch (error) { + console.error("노후관로 위치 이동 중 오류:", error); + } + + setFeatureData(row); + setIsDetailOpen(true); + setIsListOpen(false); + }, + [], + ); + + const handleDownload = useCallback(async () => { + try { + setIsListLoading(true); + const response = await fetchAgedPipeRows(1, appliedFilters, 10000); + await downloadExcelWithKoreanHeaders({ + data: response.rows as JSON[], + fileName: `노후관로_목록_${new Date().toISOString().slice(0, 10)}`, + sheetNameArray: ["노후관로 목록"], + headerRange: "A1:H1", + headerMapping: AGED_PIPE_EXCEL_HEADER, + }); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + } finally { + setIsListLoading(false); + } + }, [appliedFilters, fetchAgedPipeRows]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const map = gisMaps.current; + const cleanups: Array<() => void> = []; + const filterFn = (feature: any) => + matchesAgedPipeFilters(feature?.getProperties?.() ?? feature?.values_ ?? {}, appliedFilters); + + const decorateLayer = (layer: any) => { + if (layer?.get?.("lyrTyNm") !== PIPE_LAYER_TYPE) return; + + const originalStyle = + layer.get("_agedPipeOriginalStyle") ?? layer.getStyleFunction?.() ?? layer.getStyle?.(); + if (!layer.get("_agedPipeOriginalStyle")) { + layer.set("_agedPipeOriginalStyle", originalStyle); + } + + layer.setStyle((feature: any, resolution: number) => { + const baseStyle = + typeof originalStyle === "function" ? originalStyle(feature, resolution) : originalStyle; + const styles = Array.isArray(baseStyle) ? [...baseStyle] : baseStyle ? [baseStyle] : []; + if (filterFn(feature)) { + styles.push(agedPipeHighlightStyle); + } + return styles; + }); + + const source = layer.getSource?.(); + if (source?.on) { + const onLoadEnd = () => layer.changed?.(); + source.on("featuresloadend", onLoadEnd); + cleanups.push(() => source.un?.("featuresloadend", onLoadEnd)); + } + + layer.changed?.(); + }; + + map.getLayers().getArray().forEach(decorateLayer); + const layers = map.getLayers(); + const onAdd = (event: any) => decorateLayer(event.element); + layers.on?.("add", onAdd); + cleanups.push(() => layers.un?.("add", onAdd)); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, [appliedFilters, mapReady]); + + const FilterOverlay = useMemo( + () => + function FilterOverlayComponent(props: any) { + return ( + + ); + }, + [draftFilters, handleApplyFilters], + ); + + return ( + <> +
+ undefined} + searchBar={<>} + waterDepth={FilterOverlay} + showZoomLevel={true} + defaultZoom={7.5} + defaultLonLat={[127.75, 36]} + /> +
+ + { + const normalized = { + ...draftFilters, + elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20, + }; + setDraftFilters(normalized); + setAppliedFilters(normalized); + loadAgedPipeList(page, normalized); + }} + onDownload={handleDownload} + onRowDoubleClick={handleListRowDoubleClick} + /> + + ); +}; + +export default Map; diff --git a/apps/sn/app/html/(map-layout)/layout.tsx b/apps/sn/app/html/(map-layout)/layout.tsx new file mode 100644 index 000000000..25500a909 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/layout.tsx @@ -0,0 +1,58 @@ +/** + * ➡️ 경로 : 하수관망도 모든 페이지 + * + * 📄 Map Layout + * Next.js App Router (app/) 기반 맵 레이아웃 컴포넌트 + * + * - 메뉴 클릭 이벤트 처리 및 라우팅 + * - 현재 메뉴 정보 Zustand를 통한 상태 관리 + * - 시도/시군구/사업 구분/사업 유형 데이터 초기 로드 + * - NoFooterLayout을 통한 공통 레이아웃 적용 + * - QueryClient를 통한 데이터 캐싱 및 관리 + * - 경로 변경 시 메뉴 정보 동기화 + */ +"use client"; + +import React, { useEffect } from 'react'; +import { usePathname, useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { NoFooterLayout } from "@common/components"; +import type { MenuItem } from "@/entities/menu"; +import { useMenuStore } from "@/shared/lib"; +import useStore from "@/store/useStore"; + +export default function MapLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + + const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); + + const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); + + useEffect(() => { + fetchCtpv(); + fetchSgg(); + fetchBIZ_SE_CD(); + fetchBIZ_TY_CD(); + }, []); + + const handleMenuClick = (item: MenuItem) => { + setCurrentMenuInfo(item); + router.push(item.urlPath); + }; + + return ( + + {children} + + ); +} diff --git a/apps/sn/app/html/(map-layout)/map/components/InputSection.tsx b/apps/sn/app/html/(map-layout)/map/components/InputSection.tsx new file mode 100644 index 000000000..1d45103a1 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/InputSection.tsx @@ -0,0 +1,58 @@ +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@common/components/ui"; +import React from "react"; + +interface InputSectionProps { + title: string; + data: { year: string; type: string, bizInfoNm: string, bizType: string }[]; + sectionKey: string; + onInputChange: (section: string, index: number, field: string, value: string) => void; +} + +const InputSection: React.FC = ({ title, data, sectionKey, onInputChange }) => { + return ( +
+ {/* 섹션 제목 */} +
+

{title}

+
+ {/* 테이블 */} +
+ + + + 구분 + 진단연도 + 사업명 + 유형 + + + + {data.map((row, index) => ( + + + {row.type} + + + onInputChange(sectionKey, index, "year", e.target.value)} + className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal" + /> + + + {row.bizInfoNm} + + + {row.bizType} + + + ))} + +
+
+
+ ); +}; + +export default InputSection; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/common/codeMapper.ts b/apps/sn/app/html/(map-layout)/map/components/common/codeMapper.ts new file mode 100644 index 000000000..7b591b09c --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/common/codeMapper.ts @@ -0,0 +1,162 @@ +import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table'; + +interface CodeMapperProps { + layerName: string; + data: any; +} + +/** + * 레이어별 코드 변환 함수 + * @param layerName - 레이어 이름 + * @param data - 변환할 데이터 객체 + * @returns 변환된 데이터 객체 + */ +export const codeMapper = ({ layerName, data }: CodeMapperProps): any => { + if (!data) return data; + + const processedData = { ...data }; + + switch (layerName) { + case '하수관로': + // usg_cd 값 변환 (용도코드) + if (processedData.usg_cd) { + const usageCodeItem = MS_SWAGE_PPL_USE_CODES.find( + code => code.val === processedData.usg_cd.trim() + ); + if (usageCodeItem) { + processedData.usg_cd = usageCodeItem.text; + } + } + + // mqlt_cd 값 변환 (재질코드) + if (processedData.mqlt_cd) { + const materialCodeItem = MS_SWAGE_PPL_CODES.find( + code => code.val === processedData.mqlt_cd.trim() + ); + if (materialCodeItem) { + processedData.mqlt_cd = materialCodeItem.text; + } + } + break; + + case '하수맨홀': + // usg_cd 값 변환 (용도코드) + if (processedData.usg_cd) { + const usageCodeItem = MS_SWAGE_MHL_USE_CODES.find( + code => code.val.trim() === processedData.usg_cd.trim() + ); + if (usageCodeItem) { + processedData.usg_cd = usageCodeItem.text; + } + } + + // mqlt_cd 값 변환 (재질코드) + if (processedData.mqlt_cd) { + const materialCodeItem = MS_SWAGE_MHL_CODES.find( + code => code.val === processedData.mqlt_cd.trim() + ); + if (materialCodeItem) { + processedData.mqlt_cd = materialCodeItem.text; + } + } + break; + + case '하수펌프장': + // usg_cd 값 변환 (용도코드) + if (processedData.usg_cd) { + const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find( + code => code.val === processedData.usg_cd.trim() + ); + if (usageCodeItem) { + processedData.usg_cd = usageCodeItem.text; + } + } + break; + + case '우수토실': + // prrtymngYn 값 변환 (중점여부) + if (processedData.prrtymngYn) { + if (processedData.prrtymngYn === 'Y') { + processedData.prrtymngYn = '중점우수토실'; + } else if (processedData.prrtymngYn === 'N') { + processedData.prrtymngYn = '우수토실'; + } + } + break; + case '중점우수토실': + if (processedData.manage_no) { + processedData.mqlt_cd = processedData.manage_no; + } + if (processedData.mesure_spot_se) { + const mesureSpotSe = [{ id: '0', text: '합류식' }, { id: '1', text: '분류식' }]; + const mesureSpotSeItem = mesureSpotSe.find( + code => code.id === processedData.mesure_spot_se.trim() + ); + if (mesureSpotSeItem) processedData.mesure_spot_se = mesureSpotSeItem.text; + } + break; + + default: + // 다른 레이어는 변환하지 않음 + break; + } + + return processedData; +}; + +/** + * 저장 시 코드 역변환 함수 (한글 → 코드) + * @param layerName - 레이어 이름 + * @param data - 역변환할 데이터 객체 + * @returns 역변환된 데이터 객체 + */ +export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any => { + if (!data) return data; + + const processedData = { ...data }; + + switch (layerName) { + case '하수맨홀': + // mqlt_cd 값 역변환 (텍스트 → 코드) + if (processedData.mqlt_cd && typeof processedData.mqlt_cd === 'string') { + const materialCodeItem = MS_SWAGE_MHL_CODES.find( + code => code.text === processedData.mqlt_cd.trim() + ); + if (materialCodeItem) { + processedData.mqlt_cd = materialCodeItem.val; + } + } + break; + + case '우수토실': + // prrtymngYn 값 역변환 (텍스트 → Y/N) + if (processedData.prrtymngYn) { + if (processedData.prrtymngYn === '중점우수토실') { + processedData.prrtymngYn = 'Y'; + } else if (processedData.prrtymngYn === '우수토실') { + processedData.prrtymngYn = 'N'; + } + } + break; + + case '하수펌프장': + // usg_cd 값 역변환 (텍스트 → 코드) + if (processedData.usg_cd && typeof processedData.usg_cd === 'string') { + const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find( + code => code.text === processedData.usg_cd.trim() + ); + if (usageCodeItem) { + processedData.usg_cd = usageCodeItem.val; + } + } + break; + + default: + break; + } + + return processedData; +}; + +// 기본 내보내기 +export default { codeMapper, reverseCodeMapper }; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/common/index.ts b/apps/sn/app/html/(map-layout)/map/components/common/index.ts new file mode 100644 index 000000000..d2aed5956 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/common/index.ts @@ -0,0 +1,68 @@ +import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로 +import sewagePump from "../popupComponent/sewagePump"; // 하수펌프 +import smh from "../popupComponent/smh"; +import Ssc from "../popupComponent/Ssc"; +import StormWaterPump from "../popupComponent/stormWaterPump"; +import SwtrmtFclt from "../popupComponent/SwtrmtFclt"; + +type LayerComponentProps = { + featureData: any; + isMainSsc?: boolean; + isDrtwSewagePump?: boolean; + rainMeasureData?: any; + onSaveClick?: () => void; + onFormDataChange?: (data: any) => void; // 추가 + chmbrXcnts: string; + chmbrYdnts: string; + gisObject?: Record; // GIS 객체 추가 +}; +type LayerContentMap = { + [key: string]: { + component?: React.FC; + props?: any; + }; +}; +// 레이어 이름과 컴포넌트를 매핑 +// 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업 +const layerContentMap: LayerContentMap = { + "하수처리시설": { component: SwtrmtFclt, }, // 디지털 자료관 연계정보가 없으니 일단 주석 처리 + "하수처리구역": {}, + "우수토실": { component: Ssc, }, + "중점우수토실": { component: Ssc, }, // 중점우수토실 여부 설정 + "하수관로": { component: MsSwagePpl, }, + "하수맨홀": { component: smh, }, + "하수펌프장": { component: sewagePump }, + // "빗물펌프": { component: StormWaterPump }, + // "오수펌프": { component: sewagePump }, +}; + +// 동적 컴포넌트 반환 함수 +export const getLayerContentComponent = (layerNm: string): React.FC | null => { + const component = layerContentMap[layerNm]; + if (!component || !component.component) { + // component가 없으면 null 반환 + return () => null; + } + return component.component +}; +// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단) +export const handleInputChange = (section: string, + index: number, + field: string, + value: string, + dataMap: { + [key: string]: { + data: { year: string; type: string, bizInfoNm: string, bizType: string }[]; + setData: React.Dispatch>; + }; + } +) => { + const target = dataMap[section]; + if (!target) return; + + const updated = [...target.data]; + if (updated[index]) { + updated[index][field as keyof typeof updated[number]] = value; + } + target.setData(updated); +}; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/common/table.ts b/apps/sn/app/html/(map-layout)/map/components/common/table.ts new file mode 100644 index 000000000..4e501fb20 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/common/table.ts @@ -0,0 +1,848 @@ +// 하수관로 +export const MsSwagePplCol = [ + { id: 'fclt_mng_no', title: '시설관리번호' }, + { id: 'inst_mng_no', title: '기관관리번호' }, + { id: 'instl_ymd', title: '설치일자' }, + { id: 'mapsht_no', title: '도엽번호' }, + { id: 'pdmt_vl', title: '관경(mm)' }, + { id: 'prlg_vl', title: '연장(m)' }, + { id: 'usg_cd', title: '용도' }, + { id: 'mqlt_cd', title: '재질' }, + { id: 'avg_grdn', title: '평균구배(m/m)' }, + { id: 'drtw_mltv_area', title: '오수배수면적(m²)' }, + { id: 'excln_mltv_area', title: '우수배수면적(m²)' }, + { id: 'tmpt_dpth', title: '시점깊이(m)' }, + { id: 'tmpt_mhl_mng_no', title: '시점맨홀관리번호' }, + { id: 'scend_dpth', title: '종점깊이(m)' }, + { id: 'scend_mhl_mng_no', title: '종점맨홀관리번호' }, + { id: 'trfln_cnt', title: '차선수' }, + { id: 'vrtc_len', title: '세로길이(m)' }, + { id: 'wdth_len', title: '가로길이(m)' }, + // { id: 'rnyssn_fvlc', title: '우기 시 유속(m/s)' }, + // { id: 'clar_fvlc', title: '건기 시 유속(m/s)' }, + // { id: 'mng_inst_cd', title: '관리기관코드' }, + // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, + // { id: 'fclty_shp_cd', title: '시설물형태코드' }, + // { id: 'fid', title: 'FID' }, + // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, + // { id: 'lyrTypeNm', title: '레이어타입명' }, +] +// 하수관로 searchModal 기본정보tab +export const MsSwagePplCol2 = [ + { id: 'rowNumber', title: '일련\n번호' }, + { id: 'fcltMngNo', title: '시설\n관리번호' }, + { id: 'instMngNo', title: '기관\n관리번호' }, + { id: 'ctpvNm', title: '시도' }, + { id: 'sggNm', title: '시군구' }, + { id: "swtrmt_zone_nm", title: "처리구역" }, + { id: 'instlYmd', title: '설치일자' }, + // { id: 'mapshtNo', title: '도엽번호' }, + { id: 'usgCd', title: '용도' }, + { id: 'pdmtVl', title: '관경\n(mm)' }, + { id: 'prlgVl', title: '연장\n(m)' }, + // { id: 'avgGrdn', title: '평균구배\n(m/m)' }, + { id: 'tmptDpth', title: '시점깊이\n(m)' }, + { id: 'tmptMhlMngNo', title: '시점맨홀\n관리번호' }, + { id: 'scendDpth', title: '종점깊이\n(m)' }, + { id: 'scendMhlMngNo', title: '종점맨홀\n관리번호' }, + { id: 'vrtcLen', title: '세로길이\n(m)' }, + { id: 'wdthLen', title: '가로길이\n(m)' }, + { id: 'fcltyShpCd', title: '시설물형태코드' }, + // { id: 'fid', title: 'FID' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: 'mqltCd', title: '재질코드' }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + // { id: 'usgCd', title: '용도코드' }, + // { id: 'lyrTypeNm', title: '레이어타입명' }, +] +export const MsSwagePplExcelCol = { + 'fcltMngNo': '시설 관리번호', + 'instMngNo': '기관 관리번호', + 'ctpvNm': '시도', + 'sggNm': '시군구', + 'swtrmt_zone_nm': '처리구역', + 'instlYmd': '설치일자', + 'usgCd': '용도', + 'pdmtVl': '관경(mm)', + 'prlgVl': '연장(m)', + 'tmptDpth': '시점 깊이(m)', + 'tmptMhlMngNo': '시점맨홀 관리번호', + 'scendDpth': '종점 깊이(m)', + 'scendMhlMngNo': '종점맨홀 관리번호', + 'vrtcLen': '세로길이(m)', + 'wdthLen': '가로길이(m)', + 'fcltyShpCd': '시설물형태코드', + 'mqltCd': '재질코드', +}; +// 하수관로 용도 코드표 +export const MS_SWAGE_PPL_USE_CODES = [ + { val: "SBA000", text: "미분류" }, + { val: "SBA001", text: "합류관" }, + { val: "SBA002", text: "차집관" }, + { val: "SBA003", text: "우수관" }, + { val: "SBA004", text: "오수관" }, + { val: "SBA999", text: "기타" }, +]; +// 하수관로 재질 코드표 +export const MS_SWAGE_PPL_CODES = [ + { val: "MOP000", text: "미분류" }, + { val: "MOP001", text: "닥타일주철관(DCIP)" }, + { val: "MOP002", text: "강관(SP)" }, + { val: "MOP003", text: "아연도강관(GS)" }, + { val: "MOP004", text: "염화비닐관(PVC)" }, + { val: "MOP005", text: "폴리에틸렌관(PE)" }, + { val: "MOP006", text: "스테인레스관(SSP)" }, + { val: "MOP007", text: "동관(COP)" }, + { val: "MOP008", text: "P.S.콘크리트관(PC)" }, + { val: "MOP009", text: "석면시멘트관(AS)" }, + { val: "MOP010", text: "회주철관(CIP)" }, + { val: "MOP011", text: "연관(LP)" }, + { val: "MOP012", text: "흉관(HP)" }, + { val: "MOP013", text: "철근콘크리트관(RC)" }, + { val: "MOP014", text: "강관(ST)" }, + { val: "MOP015", text: "주철관(CI)" }, + { val: "MOP016", text: "화학제품류관(PV)" }, + { val: "MOP017", text: "도관(VC)" }, + { val: "MOP018", text: "석축(SE)" }, + { val: "MOP019", text: "도복장 강관(SP)" }, + { val: "MOP020", text: "흉관(EP)" }, + { val: "MOP021", text: "아연도강관(GP)" }, + { val: "MOP022", text: "청동관(BR)" }, + { val: "MOP023", text: "고무몰딩(RM)" }, + { val: "MOP024", text: "주강(CS)" }, + { val: "MOP025", text: "VR관" }, + { val: "MOP026", text: "HDPE관" }, + { val: "MOP027", text: "시멘트라이닝주철관(C)" }, + { val: "MOP028", text: "수도용 PE관(PE)" }, + { val: "MOP029", text: "석면시멘트관(AC)" }, + { val: "MOP030", text: "폴리에틸렌분체라이닝강관(PEP)" }, + { val: "MOP031", text: "에폭시코팅강관(EPS)" }, + { val: "MOP032", text: "HI-3P" }, + { val: "MOP033", text: "철근콘크리트박스(RCB)" }, + { val: "MOP034", text: "콘크리트중선함(CD)" }, + { val: "MOP035", text: "후랙시불전선함(FP)" }, + { val: "MOP036", text: "전선함(FD)" }, + { val: "MOP038", text: "이중보온관" }, + { val: "MOP039", text: "폴리에틸렌피복강관(PLP)" }, + { val: "MOP041", text: "FRP" }, + { val: "MOP042", text: "강재" }, + { val: "MOP999", text: "기타" }, + { val: "MOP800", text: "2중벽PE관" }, + { val: "MOP801", text: "3중벽PE관" }, + { val: "MOP802", text: "가스배관용 강관" }, + { val: "MOP803", text: "강관(피복 Double, 용접접합)" }, + { val: "MOP804", text: "강관(피복 Single, 용접접합)" }, + { val: "MOP805", text: "강관(CP)" }, + { val: "MOP806", text: "강철" }, + { val: "MOP807", text: "강화플라스틱복합관" }, + { val: "MOP808", text: "경질염화비닐관(HI-VP)" }, + { val: "MOP809", text: "고강성PVC이중벽관" }, + { val: "MOP810", text: "고광도PVC이중벽관" }, + { val: "MOP811", text: "고밀도폴리에틸렌관(THP)" }, + { val: "MOP812", text: "고밀도폴리에틸렌파상형이중벽관" }, + { val: "MOP813", text: "공장제 박스관거" }, + { val: "MOP814", text: "관로없음(알케이블)" }, + { val: "MOP815", text: "나선형강관" }, + { val: "MOP816", text: "내충격수도관" }, + { val: "MOP817", text: "내충격용하수도용경질염화비닐관(HI-VG)" }, + { val: "MOP818", text: "내충격HI-VG2관" }, + { val: "MOP819", text: "내충격PVC하수관" }, + { val: "MOP820", text: "녹석(돌산호과 일종)" }, + { val: "MOP821", text: "동관(구리관, 용접)" }, + { val: "MOP822", text: "둥근잔돌" }, + { val: "MOP823", text: "무근콘크리트" }, + { val: "MOP824", text: "백관" }, + { val: "MOP825", text: "복합철판파형관" }, + { val: "MOP826", text: "분체라이닝관" }, + { val: "MOP827", text: "석축박스" }, + { val: "MOP828", text: "석축철콘" }, + { val: "MOP829", text: "수도용경질염화비관" }, + { val: "MOP830", text: "수도용경질염화비닐이음관" }, + { val: "MOP831", text: "수도용엑포시수지분체내외면코팅강관" }, + { val: "MOP832", text: "수지파형강관(PCS)" }, + { val: "MOP833", text: "수지CSP" }, + { val: "MOP834", text: "스테인레스 주름관" }, + { val: "MOP835", text: "스테인레스관(STS)" }, + { val: "MOP836", text: "스테인레스스틸파이프관" }, + { val: "MOP837", text: "아스콘" }, + { val: "MOP838", text: "아스팔트" }, + { val: "MOP839", text: "아스팔트도복장강관(ASP)" }, + { val: "MOP840", text: "아연" }, + { val: "MOP841", text: "아연도금(백관)" }, + { val: "MOP842", text: "알루미늄" }, + { val: "MOP843", text: "알루미늄합금" }, + { val: "MOP844", text: "압력배관용 탄소강관" }, + { val: "MOP845", text: "양철관" }, + { val: "MOP846", text: "양철주름관(CSP)" }, + { val: "MOP847", text: "연합인슈강판" }, + { val: "MOP848", text: "와사관(GSP)" }, + { val: "MOP849", text: "원심력철근콘크리트" }, + { val: "MOP850", text: "유리섬유관(GRP)" }, + { val: "MOP851", text: "유리섬유복합관(GRP)" }, + { val: "MOP852", text: "이음매없는동및동합금관" }, + { val: "MOP853", text: "일반배관용스테인리스강관" }, + { val: "MOP854", text: "전압 콘크리트관" }, + { val: "MOP855", text: "주름관" }, + { val: "MOP856", text: "주름주철파형강관" }, + { val: "MOP857", text: "지중 저압케이블 입상관" }, + { val: "MOP858", text: "진동및전압철근콘크리트관" }, + { val: "MOP859", text: "철근콘크리트" }, + { val: "MOP860", text: "철근콘크리트 블록" }, + { val: "MOP861", text: "철근콘크리트조립흙막이" }, + { val: "MOP862", text: "철판매입관" }, + { val: "MOP863", text: "청동" }, + { val: "MOP864", text: "코아식프리스트레스트콘크리트관" }, + { val: "MOP865", text: "콘크리트" }, + { val: "MOP866", text: "콘크리트 폴륨관" }, + { val: "MOP867", text: "콘크리트블록" }, + { val: "MOP868", text: "탄소강" }, + { val: "MOP869", text: "탄소강(카본스틸)" }, + { val: "MOP870", text: "탄소주강" }, + { val: "MOP871", text: "트러후" }, + { val: "MOP872", text: "파선형강관(RCF)" }, + { val: "MOP873", text: "파형강관(CSP)" }, + { val: "MOP874", text: "파형관" }, + { val: "MOP875", text: "파형플라스틱관" }, + { val: "MOP876", text: "폴리머관" }, + { val: "MOP877", text: "폴리에스테르수지콘크리트관" }, + { val: "MOP878", text: "폴리에틸렌 전선관" }, + { val: "MOP879", text: "폴리에틸렌 피복강관" }, + { val: "MOP880", text: "폴리에틸렌관 1호관" }, + { val: "MOP881", text: "폴리에틸렌관 2호관" }, + { val: "MOP882", text: "폴리에틸렌관(2중벽)" }, + { val: "MOP883", text: "폴리에틸렌관(3중벽)" }, + { val: "MOP884", text: "폴리에틸렌분체라이닝" }, + { val: "MOP885", text: "폴리에틸렌이중벽하수관" }, + { val: "MOP886", text: "폴리탄산에스테르" }, + { val: "MOP887", text: "프리스트레스트콘크리트실린더관" }, + { val: "MOP888", text: "플라스틱" }, + { val: "MOP889", text: "플라스틱주름관" }, + { val: "MOP890", text: "하수도용고강성경질염화비닐이중벽주름관" }, + { val: "MOP891", text: "현장타설관" }, + { val: "MOP892", text: "현장타설철근콘크리트관" }, + { val: "MOP893", text: "흑관" }, + { val: "MOP894", text: "흙" }, + { val: "MOP895", text: "AP" }, + { val: "MOP896", text: "API관" }, + { val: "MOP897", text: "ASP" }, + { val: "MOP898", text: "CMCIP" }, + { val: "MOP899", text: "CSP" }, + { val: "MOP900", text: "DRP" }, + { val: "MOP901", text: "DSP" }, + { val: "MOP902", text: "DTC관" }, + { val: "MOP903", text: "ELP" }, + { val: "MOP904", text: "EPP" }, + { val: "MOP905", text: "FC관" }, + { val: "MOP906", text: "H" }, + { val: "MOP907", text: "H6" }, + { val: "MOP908", text: "HCP" }, + { val: "MOP909", text: "HI_2P관" }, + { val: "MOP910", text: "HI-KP" }, + { val: "MOP911", text: "HL" }, + { val: "MOP912", text: "IP" }, + { val: "MOP913", text: "KCGP" }, + { val: "MOP914", text: "KP관" }, + { val: "MOP915", text: "P2S관" }, + { val: "MOP916", text: "PC박스(PCB)" }, + { val: "MOP917", text: "PCS" }, + { val: "MOP918", text: "PDP" }, + { val: "MOP919", text: "PE이중벽관" }, + { val: "MOP920", text: "PE적측벽1종관" }, + { val: "MOP921", text: "PFP관" }, + { val: "MOP922", text: "PG관" }, + { val: "MOP923", text: "PIP관(이중보온관)" }, + { val: "MOP924", text: "PLCSP" }, + { val: "MOP925", text: "PS" }, + { val: "MOP926", text: "PUB" }, + { val: "MOP927", text: "PVB" }, + { val: "MOP928", text: "PVC이중벽관(PVC2)" }, + { val: "MOP929", text: "PVC-DC" }, + { val: "MOP930", text: "PVC-DV" }, + { val: "MOP931", text: "SBK" }, + { val: "MOP932", text: "SCP" }, + { val: "MOP933", text: "SEB" }, + { val: "MOP934", text: "SIS관(이중보온관)" }, + { val: "MOP935", text: "SMC" }, + { val: "MOP936", text: "STW" }, + { val: "MOP937", text: "SUS" }, + { val: "MOP938", text: "THP나선관" }, + { val: "MOP939", text: "U관" }, + { val: "MOP940", text: "VG2" }, + { val: "MOP941", text: "WSP(와사피)" }, + { val: "MOP942", text: "Steel Grating" }, + { val: "MOP943", text: "모래" }, + { val: "MOP944", text: "인조석" }, + { val: "MOP945", text: "SPOL" }, + { val: "MOP946", text: "PCF관(강판보강 PE 나선형관)" }, + { val: "MOP947", text: "PVC3(PVC3중벽관)" }, + { val: "MOP948", text: "PE다중벽관" }, + { val: "MOP977", text: "전주용입상관" }, + { val: "MOP978", text: "MD관" }, +]; +// 하수관로 'fcltyShpCd' '시설물형태코드' +export const MS_SWAGE_PPL_SHAPE = [ + { val: "FOR000 ", text: "미분류" }, + { val: "FOR001 ", text: "원형" }, + { val: "FOR002 ", text: "원추형" }, + { val: "FOR003 ", text: "원통형" }, + { val: "FOR004 ", text: "구형" }, + { val: "FOR005 ", text: "타원형" }, + { val: "FOR006 ", text: "난원" }, + { val: "FOR007 ", text: "반원형" }, + + { val: "FOR010 ", text: "피라미드형" }, + { val: "FOR011 ", text: "사면체형" }, + { val: "FOR012 ", text: "기둥,축" }, + { val: "FOR013 ", text: "돛대,활대처럼 둥글고 긴 원재" }, + { val: "FOR014 ", text: "아치형" }, + { val: "FOR015 ", text: "다중아치형" }, + { val: "FOR016 ", text: "돔형" }, + { val: "FOR017 ", text: "고형체형" }, + + { val: "FOR020 ", text: "사각형" }, + { val: "FOR021 ", text: "평방형" }, + { val: "FOR022 ", text: "다이아몬드형" }, + { val: "FOR023 ", text: "사다리꼴" }, + { val: "FOR024 ", text: "각형" }, + { val: "FOR025 ", text: "마제형관" }, + { val: "FOR026 ", text: "태형" }, + + { val: "FOR030 ", text: "A형" }, + { val: "FOR031 ", text: "H형" }, + { val: "FOR032 ", text: "I형" }, + { val: "FOR033 ", text: "Y형" }, + { val: "FOR034 ", text: "T형" }, + + { val: "FOR050 ", text: "불규칙한 형태" }, + + { val: "FOR900 ", text: "계란형" }, + { val: "FOR901 ", text: "구(사각)형" }, + { val: "FOR902 ", text: "높이조절철개형" }, + { val: "FOR903 ", text: "박스형(RCB)" }, + { val: "FOR904 ", text: "반월개거" }, + { val: "FOR905 ", text: "반월개거(측구)" }, + { val: "FOR906 ", text: "벽돌조" }, + { val: "FOR907 ", text: "사각철판" }, + { val: "FOR908 ", text: "석축개거" }, + { val: "FOR909 ", text: "석축개거(측구)" }, + { val: "FOR910 ", text: "승수로" }, + { val: "FOR911 ", text: "옹벽개거" }, + { val: "FOR912 ", text: "옹벽개거(측구)" }, + { val: "FOR913 ", text: "이단" }, + { val: "FOR914 ", text: "이중침전형" }, + { val: "FOR915 ", text: "자연개거" }, + { val: "FOR916 ", text: "자연개거(측구)" }, + { val: "FOR917 ", text: "조립식" }, + { val: "FOR918 ", text: "지상관" }, + { val: "FOR919 ", text: "지상단구" }, + { val: "FOR920 ", text: "지상쌍구" }, + { val: "FOR921 ", text: "지하단구" }, + { val: "FOR922 ", text: "지하쌍구" }, + { val: "FOR923 ", text: "직사각형" }, + { val: "FOR924 ", text: "타설식" }, + { val: "FOR925 ", text: "토류형" }, + { val: "FOR926 ", text: "흄관원형" }, + { val: "FOR927 ", text: "L형측구" }, + { val: "FOR928 ", text: "LO형측구" }, + { val: "FOR929 ", text: "LU형측구" }, + { val: "FOR930 ", text: "PVC이중벽관" }, + { val: "FOR931 ", text: "RCB 맨홀" }, + { val: "FOR932 ", text: "U형" }, + { val: "FOR933 ", text: "U형측구(미복개)" }, + { val: "FOR934 ", text: "U형측구(복개)" }, + + { val: "FOR999 ", text: "기타" } +]; +// 하수처리시설 +export const SwtrmtFcltCol = [ + { id: 'swtrmt_fclt_nm', title: '하수처리시설명' }, + { id: 'fclt_cpct', title: '시설용량(㎥/d)' }, + { id: 'rgn_expln', title: '지역설명' }, + { id: 'cmcn_ymd', title: '준공일자' }, + { id: 'oprtng_strt_ymd', title: '가동개시일자' }, + { id: 'clsg_ymd', title: '폐쇄일자' }, + // { id: 'prcs_msrc_cd', title: '처리공법코드' }, + // { id: 'lae_cd', title: '지자체코드' }, + { id: 'lae_nm', title: '지자체명' }, + { id: 'ctpv_nm', title: '시도명' }, + { id: 'sgg_nm', title: '시군구명' }, + // { id: 'sarea_cd', title: '권역코드' }, + { id: 'sarea_nm', title: '권역명' }, + { id: 'lrge_sarea_nm', title: '대권역명' }, + { id: 'rvr_nm', title: '하천명' }, + { id: 'addr', title: '주소' }, + { id: 'dtl_addr', title: '상세주소' }, + { id: 'rdnm_addr', title: '도로명주소' }, + { id: 'dtl_rdnm_addr', title: '상세도로명주소' }, + { id: 'dschg_wtst_nm', title: '방류수계명' }, + { id: 'plln_area', title: '부지면적(m²)' }, + { id: 'mng_rprtv_bzenty_nm', title: '관리대행업제명' }, + { id: 'swtrmt_ppltn_cnt', title: '하수처리인구수(인)' }, + { id: 'prcs_zone_area_sum', title: '처리구역면적합계(m²)' }, + { id: 'xcrd', title: 'X좌표' }, + { id: 'ycrd', title: 'Y좌표' }, + // { id: 'oprtngSttsCd', title: '가동상태코드' }, + { id: 'rprs_msrc_cn', title: '대표공법내용' }, + // { id: 'stty_crtr_bod', title: '법정기준생물화학적산소요구량(mg/L)' }, + // { id: 'stty_crtr_toc', title: '법정기준TOC((mg/L))' }, + // { id: 'stty_crtr_ss', title: '법정기준SS(mg/L)' }, + // { id: 'stty_crtr_tn', title: '법정기준총질소(mg/L)' }, + // { id: 'stty_crtr_tph', title: '법정기준총인(mg/L)' }, + // { id: 'stty_crtr_clbcl_cnt', title: '법정기준대장균수(군수/100mL)' }, + { id: 'bsn_nm', title: '유역명' }, + // { id: 'obsrvnSptCd', title: '관측지점코드' }, +]; +export const SwtrmtFcltCol2 = [ + { id: 'rowNumber', title: '일련\n번호' }, + { id: 'swtrmtFcltNm', title: '하수처리\n시설명' }, + { id: 'fcltCpct', title: '시설\n용량\n(㎥/d)' }, + { id: 'rgnExpln', title: '지역설명' }, + // { id: 'cmcnYmd', title: '준공일자' }, + { id: 'oprtngStrtYmd', title: '가동개시일자' }, + // { id: 'clsgYmd', title: '폐쇄일자' }, + // { id: 'prcsMsrcCd', title: '처리공법코드' }, + // { id: 'laeCd', title: '지자체코드' }, + { id: 'laeNm', title: '지자체명' }, + // { id: 'ctpvNm', title: '시도명' }, + // { id: 'sggNm', title: '시군구명' }, + // { id: 'sareaCd', title: '권역코드' }, + { id: 'sareaNm', title: '권역명' }, + // { id: 'lrgeSareaNm', title: '대권역명' }, + { id: 'rvrNm', title: '하천명' }, + // { id: 'addr', title: '주소' }, + // { id: 'dtlAddr', title: '상세주소' }, + // { id: 'rdnmAddr', title: '도로명주소' }, + // { id: 'dtlRdnmAddr', title: '상세도로명주소' }, + { id: 'dschgWtstNm', title: '방류수계명' }, + // { id: 'pllnArea', title: '부지면적(m²)' }, + // { id: 'mngRprtvBzentyNm', title: '관리대행업체명' }, + { id: 'swtrmtPpltnCnt', title: '하수\n처리\n인구수\n(인)' }, + // { id: 'prcsZoneAreaSum', title: '처리구역면적합계(m²)' }, + // { id: 'xcrd', title: 'X좌표' }, + // { id: 'ycrd', title: 'Y좌표' }, + // { id: 'oprtngSttsCd', title: '가동상태코드' }, + { id: 'rprsMsrcCn', title: '대표\n공법\n내용' }, + // { id: 'sttyCrtrBod', title: '법정기준생물화학적산소요구량(mg/L)' }, + // { id: 'sttyCrtrToc', title: '법정기준TOC(mg/L)' }, + // { id: 'sttyCrtrSs', title: '법정기준SS(mg/L)' }, + // { id: 'sttyCrtrTn', title: '법정기준총질소(mg/L)' }, + // { id: 'sttyCrtrTph', title: '법정기준총인(mg/L)' }, + // { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수(군수/100mL)' }, + // { id: 'bsnNm', title: '유역명' }, + // { id: 'obsrvnSptCd', title: '관측지점코드' }, +]; +export const SwtrmtFcltExcelCol = { + 'swtrmtFcltNm': '하수처리시설명', + 'fcltCpct': '시설 용량(㎥/d)', + 'rgnExpln': '지역설명', + 'oprtngStrtYmd': '가동개시 일자', + 'laeNm': '지자체명', + 'sareaNm': '권역명', + 'rvrNm': '하천명', + 'dschgWtstNm': '방류수계명', + 'swtrmtPpltnCnt': '하수 처리 인구수(인)', + 'rprsMsrcCn': '대표 공법 내용' +}; + +// 하수처리구역 +export const SwtrmtPrcsZoneCol = [ + // { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' }, + { id: 'swtrmt_zone_nm', title: '하수처리구역명' }, + // { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, + // { id: 'ctpv_cd', title: '시도코드' }, + { id: 'ctpv_nm', title: '시도명' }, + // { id: 'sgg_cd', title: '시군구코드' }, + { id: 'sgg_nm', title: '시군구명' }, + { id: 'swtrmt_fclt_nm', title: '하수처리시설명' }, +]; +export const SwtrmtPrcsZoneCol2 = [ + { id: 'rowNumber', title: '일련번호' }, + // { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' }, + { id: 'swtrmtZoneNm', title: '하수처리구역명' }, + // { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, + // { id: 'ctpv_cd', title: '시도코드' }, + { id: 'ctpvNm', title: '시도' }, + // { id: 'sgg_cd', title: '시군구코드' }, + { id: 'sggNm', title: '시군구' }, + { id: 'swtrmtFcltNm', title: '하수처리시설명' }, +]; +export const SwtrmtPrcsZoneExcelCol = { + 'swtrmtZoneNm': '하수처리구역명', + 'ctpvNm': '시도', + 'sggNm': '시군구', + 'swtrmtFcltNm': '하수처리시설명' +}; + +// 하수맨홀 +export const MsSwageMhlCol = [ + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, + { id: 'fclt_mng_no', title: '시설관리번호' }, + // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, + { id: 'mapsht_no', title: '도엽번호' }, + // { id: 'mng_inst_cd', title: '관리기관코드' }, + { id: 'instl_ymd', title: '설치일자' }, + { id: 'last_drdgn_ymd', title: '최종준설일자' }, + // { id: 'usg_cd', title: '용도코드' }, + // { id: 'fclty_shp_cd', title: '시설물형태코드' }, + // { id: 'mhl_knd_cd', title: '맨홀종류코드' }, + { id: 'swage_mhL_diam_vl', title: '하수맨홀구경(mm)' }, + { id: 'mqlt_cd', title: '재질' }, + { id: 'wdth_len', title: '가로길이(m)' }, + { id: 'vrtc_len', title: '세로길이(m)' }, + // { id: 'ang_dir', title: '표고' }, + { id: 'altd_vl', title: '지표고(EL.m)' }, + { id: 'dpthl_vl', title: '맨홀저고(EL.m)' }, + // { id: 'inst_mng_no', title: '기관관리번호' } +]; +export const MsSwageMhlCol2 = [ + { id: 'rowNumber', title: '일련번호' }, + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: 'fcltMngNo', title: '시설관리번호' }, + { id: 'ctpvNm', title: '시도' }, + { id: 'sggNm', title: '시군구' }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + // { id: 'mapshtNo', title: '도엽번호' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: 'instlYmd', title: '설치일자' }, + // { id: 'lastDrdgnYmd', title: '최종준설일자' }, + { id: 'usgCd', title: '용도' }, + // { id: 'fcltyShpCd', title: '시설물형태코드' }, + // { id: 'mhlKndCd', title: '맨홀종류코드' }, + { id: 'swageMhlDiamVl', title: '하수맨홀구경(mm)' }, + // { id: 'mqltCd', title: '재질코드' }, + { id: 'wdthLen', title: '가로길이(m)' }, + { id: 'vrtcLen', title: '세로길이(m)' }, + // { id: 'angDir', title: '표고' }, + { id: 'altdVl', title: '지표고(EL.m)' }, + { id: 'dpthlVl', title: '맨홀저고(EL.m)' }, + // { id: 'instMngNo', title: '기관관리번호' } +]; +export const MsSwageMhlExcelCol = { + 'fcltMngNo': '시설관리번호', + 'ctpvNm': '시도', + 'sggNm': '시군구', + 'instlYmd': '설치일자', + 'usgCd': '용도', + 'swageMhlDiamVl': '하수맨홀구경(mm)', + 'wdthLen': '가로길이(m)', + 'vrtcLen': '세로길이(m)', + 'altdVl': '지표고(EL.m)', + 'dpthlVl': '맨홀저고(EL.m)' +}; +// 하수맨홀 뚜껑재질 코드표 +export const MS_SWAGE_MHL_CODES = [ + { val: "SBC000", text: "미분류" }, + { val: "SBC001", text: "주철" }, + { val: "SBC002", text: "철판" }, + { val: "SBC003", text: "콘크리트" }, + { val: "SBC004", text: "그레이팅" }, + { val: "SBC091", text: "칼라" }, + { val: "SBC092", text: "S.G" }, + { val: "SBC999", text: "기타" }, + { val: "SBC900", text: "각주철" }, + { val: "SBC901", text: "강재" }, + { val: "SBC902", text: "나무" }, + { val: "SBC903", text: "나무,널판지,합판" }, + { val: "SBC904", text: "대리석" }, + { val: "SBC905", text: "뚜껑없음" }, + { val: "SBC906", text: "보도용콘크리트" }, + { val: "SBC907", text: "스틸(ST)" }, + { val: "SBC908", text: "신형 CON" }, + { val: "SBC909", text: "알루미늄" }, + { val: "SBC910", text: "염화비닐(PE)" }, + { val: "SBC911", text: "인조석" }, + { val: "SBC912", text: "인조석(주철)" }, + { val: "SBC913", text: "인조CONC" }, + { val: "SBC914", text: "철, 철근" }, + { val: "SBC915", text: "칼라콘크리트" }, + { val: "SBC916", text: "플라스틱" }, + { val: "SBC917", text: "PVC" }, + { val: "SBC918", text: "SP" }, + { val: "SBC919", text: "SS" }, + { val: "SBC920", text: "닥타일주철" }, + { val: "SBC921", text: "철근G" }, + { val: "SBC922", text: "폴리에틸렌(PE)" }, + { val: "SBC923", text: "회주철" } +]; +// 하수맨홀 용도 코드표 +export const MS_SWAGE_MHL_USE_CODES = [ + { val: "SMU000 ", text: "미분류" }, + { val: "SMU001 ", text: "우수" }, + { val: "SMU002 ", text: "오수" }, + { val: "SMU003 ", text: "집수" }, + { val: "SMU004 ", text: "합류" }, + { val: "SMU005 ", text: "차집" }, + { val: "SMU999 ", text: "기타" }, + { val: "SMU900 ", text: "1호" }, + { val: "SMU901 ", text: "2호" }, + { val: "SMU902 ", text: "3호" }, + { val: "SMU903 ", text: "4호" }, + { val: "SMU904 ", text: "5호" }, + { val: "SMU905 ", text: "6호" }, + { val: "SMU906 ", text: "7호" }, + { val: "SMU907 ", text: "8호" }, + { val: "SMU908 ", text: "구거용맨홀" }, + { val: "SMU909 ", text: "부관부착맨홀" }, + { val: "SMU910 ", text: "조립식맨홀" }, + { val: "SMU911 ", text: "특1호" }, + { val: "SMU912 ", text: "특2호" }, + { val: "SMU913 ", text: "특3호" }, + { val: "SMU914 ", text: "특4호" }, + { val: "SMU915 ", text: "특5호" }, + { val: "SMU916 ", text: "하수맨홀용도" }, + { val: "SMU917 ", text: "현장타설관거용" } +]; + +// 우수펌프장 +export const ExclnSewagePumpCol = [ + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: 'fclt_mng_no', title: '시설관리번호' }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + { id: 'mapsht_no', title: '도엽번호' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: 'instl_ymd', title: '설치일자' }, + { id: 'swage_pmstn_nm', title: '하수펌프장명' }, + { id: 'plln_area', title: '부지면적(m²)' }, + // { id: 'opngSttsCd', title: '개통상태코드' }, + // { id: 'usgCd', title: '용도코드' }, + { id: 'daily_prcs_cpct', title: '일일처리용량(㎥)' }, + { id: 'max_impnd_cpct', title: '최대저수용량(㎥)' }, + { id: 'altd_vl', title: '표고(EL.m)' }, + { id: 'wtlv_vl', title: '수위(m)' }, + { id: 'excln_wpmp_ablt_vl', title: '우수양수능력(m²/d)' }, + // { id: 'instMngNo', title: '기관관리번호' }, +] +export const ExclnSewagePumpCol2 = [ + { id: 'rowNumber', title: '일련번호' }, + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: 'fcltMngNo', title: '시설관리번호' }, + { id: 'ctpvNm', title: '시도' }, + { id: 'sggNm', title: '시군구' }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + // { id: 'mapshtNo', title: '도엽번호' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: 'swagePmstnNm', title: '하수펌프장명' }, + { id: 'instlYmd', title: '설치일자' }, + // { id: 'pllnArea', title: '부지면적(m²)' }, + // { id: 'opngSttsCd', title: '개통상태코드' }, + // { id: 'usgCd', title: '용도코드' }, + { id: 'dailyPrcsCpct', title: '일일처리용량(㎥)' }, + { id: 'maxImpndCpct', title: '최대저수용량(㎥)' }, + { id: 'altdVl', title: '표고(EL.m)' }, + { id: 'wtlvVl', title: '수위(m)' }, + { id: 'exclnWpmpAbltVl', title: '우수양수능력(m²/d)' }, + // { id: 'instMngNo', title: '기관관리번호' }, +] +export const ExclnSewagePumpExcelCol = { + 'fcltMngNo': '시설관리번호', + 'ctpvNm': '시도', + 'sggNm': '시군구', + 'swagePmstnNm': '하수펌프장명', + 'instlYmd': '설치일자', + 'dailyPrcsCpct': '일일처리용량(㎥)', + 'maxImpndCpct': '최대저수용량(㎥)', + 'altdVl': '표고(EL.m)', + 'wtlvVl': '수위(m)', + 'exclnWpmpAbltVl': '우수양수능력(m²/d)' +}; +// 오수펌프장 +export const DrtwSewagePumpCol = [ + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: 'fclt_mng_no', title: '시설관리번호' }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + { id: 'mapsht_no', title: '도엽번호' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: 'instl_ymd', title: '설치일자' }, + { id: 'swage_pmstn_nm', title: '하수펌프장명' }, + { id: 'plln_area', title: '부지면적(m²)' }, + // { id: 'opngSttsCd', title: '개통상태코드' }, + // { id: 'usgCd', title: '용도코드' }, + { id: 'daily_prcs_cpct', title: '일일처리용량(㎥)' }, + { id: 'max_impnd_cpct', title: '최대저수용량(㎥)' }, + { id: 'altd_vl', title: '표고(EL.m)' }, + { id: 'wtlv_vl', title: '수위(m)' }, + { id: 'excln_wpmp_ablt_vl', title: '오수양수능력(m²/d)' }, + // { id: 'instMngNo', title: '기관관리번호' }, +] +export const DrtwSewagePumpCol2 = [ + { id: 'rowNumber', title: '일련번호' }, + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: 'fcltMngNo', title: '시설관리번호' }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + // { id: 'mapshtNo', title: '도엽번호' }, + { id: 'ctpvNm', title: '시도' }, + { id: 'sggNm', title: '시군구' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: 'swagePmstnNm', title: '하수펌프장명' }, + { id: 'instlYmd', title: '설치일자' }, + // { id: 'pllnArea', title: '부지면적(m²)' }, + // { id: 'opngSttsCd', title: '개통상태코드' }, + // { id: 'usgCd', title: '용도코드' }, + { id: 'dailyPrcsCpct', title: '일일처리용량(㎥)' }, + { id: 'maxImpndCpct', title: '최대저수용량(㎥)' }, + { id: 'altdVl', title: '표고(EL.m)' }, + { id: 'wtlvVl', title: '수위(m)' }, + { id: 'exclnWpmpAbltVl', title: '오수양수능력(m²/d)' }, + // { id: 'instMngNo', title: '기관관리번호' }, +] +// 펌프장 용도 코드표 +export const DRTW_SEWAGE_PUMP_USE_CODES = [ + { val: "SBE000", text: "미분류" }, + { val: "SBE001", text: "빗물펌프장" }, + { val: "SBE002", text: "오수펌프장" }, + { val: "SBE003", text: "처리장내펌프장" }, + { val: "SBE900", text: "오수배제" }, + { val: "SBE901", text: "우수배제" }, + { val: "SBE999", text: "기타" }, +]; + +// 우수토실 +export const stofcbrCol = [ + // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, + { id: 'fclt_mng_no', title: '시설관리번호' }, + // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, + { id: 'mapsht_no', title: '도엽번호' }, + // { id: 'mng_inst_cd', title: '관리기관코드' }, + { id: 'instl_ymd', title: '설치일자' }, + // { id: 'fclty_shp_cd', title: '시설물형태코드' }, + { id: 'worflw_wall_hgt', title: '월류벽높이(m)' }, + // { id: 'mhl_exst_yn_cd', title: '맨홀존재여부코드' }, + // { id: 'swage_mhl_ftre_smbol_cd', title: '하수맨홀지형지물부호코드' }, + { id: 'swage_mhl_mng_no', title: '하수맨홀관리번호' }, + { id: 'drtn_angl', title: '방향각도(°)' }, +] +export const stofcbrCol2 = [ + { id: 'rowNumber', title: '일련번호' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: 'fcltMngNo', title: '시설관리번호' }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + { id: 'mapshtNo', title: '도엽번호' }, + { id: 'ctpvNm', title: '시도' }, + { id: 'sggNm', title: '시군구' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: 'instlYmd', title: '설치일자' }, + // { id: 'fcltyShpCd', title: '시설물형태코드' }, + { id: 'worflwWallHgt', title: '월류벽높이(m)' }, + // { id: 'mhlExstYnCd', title: '맨홀존재여부코드' }, + // { id: 'swageMhlFtreSmbolCd', title: '하수맨홀지형지물부호코드' }, + { id: 'swageMhlMngNo', title: '하수맨홀관리번호' }, + { id: 'drtnAngl', title: '방향각도(°)' }, +] +export const stofcbrExcelCol = { + 'fcltMngNo': '시설관리번호', + 'mapshtNo': '도엽번호', + 'ctpvNm': '시도', + 'sggNm': '시군구', + 'instlYmd': '설치일자', + 'worflwWallHgt': '월류벽높이(m)', + 'swageMhlMngNo': '하수맨홀관리번호', + 'drtnAngl': '방향각도(°)' +}; + +// 중점우수토실 측정지점 +export const untrtSwgRainCol1 = [ + { id: 'manage_no', title: '관리번호' }, + { id: 'swr_ppl_eqp_nm', title: '처리구역' }, + { id: 'swr_ppl_trt_nm', title: '처리분구' }, + { id: 'addr', title: '인근주소' }, + { id: 'chmbr_xcnts', title: '토실 위도' }, + { id: 'chmbr_ydnts', title: '토실 경도' } +]; + +// 중점우수토실 측정지점 시설현황 +export const untrtSwgRainCol2 = [ + { id: 'mesure_spot_se', title: '구분' }, // 유량계측정방식 기타 + { id: 'discharge_pipe_spec', title: '방류관로 규격(m)' }, + { id: 'swr_ppl_eqp_ar', title: '배수구역 면적(m²)' }, + { id: 'swr_trt_popl', title: '하수처리 인구(인)' }, + { id: 'screen_instl_at', title: '스크린설치 여부' }, + { id: 'weir_hg', title: '위어 높이(m)' }, + { id: 'weir_lt', title: '위어 길이(m)' } +]; + +// 중점우수토실 강우정보 (id값 틀릴 수 있음) +export const untrtSwgRainCol3 = [ + { id: 'rainBeginDt', title: '강우시작일시' }, + { id: 'rainEndDt', title: '강우종료일시' }, + { id: 'precdngDry', title: '선행건기(h)' }, + { id: 'rainInfo', title: '강우정보' }, + { id: 'rainFall', title: '누적강우량(mm)' }, + { id: 'cntncTime', title: '지속시간(h)' }, + { id: 'rainIn', title: '강우강도(mm/h)' } +]; + +// 중점우수토실 측정정보 (id값 틀릴 수 있음) +export const untrtSwgRainCol4 = [ + { id: 'mesureBeginDt', title: '측정시작일시' }, + { id: 'mesureEndDt', title: '측정종료일시' }, + { id: 'mesureIntrvl', title: '측정간격(분)' }, + { id: 'mesureMhrlsInstlLc', title: '측정기기 설치위치' }, + { id: 'flwmtrCombined', title: '유량계 종류 및 모델명' }, // 합쳐진 컬럼 + { id: 'qltwtrBeginDt', title: '채수시작일시' }, + { id: 'qltwtrEndDt', title: '채수종료일시' }, + { id: 'qltwtrCo', title: '채수횟수' } +]; + +// 중점우수토실 수량/수질측정 정보 +export const untrtSwgRainCol5 = [ + { id: 'odr', title: '회차' }, + { id: 'mesureDt', title: '측정일자' }, + { id: 'mesureQy', title: '수량(m³)' }, + { id: 'mesureBod', title: 'BOD(mg/L)' }, + { id: 'mesureToc', title: 'TOC(mg/L)' }, + { id: 'mesureLoad', title: '오염부하량(kg)' } +]; + +// 🎯 날짜 필드들 (yyyymmdd -> yyyy년 mm월 dd일) +export const dateFields = [ + 'instl_ymd', 'last_drdgn_ymd', 'cmcn_ymd', 'oprtng_strt_ymd', 'clsg_ymd', + 'mesure_begin_dt', 'mesure_end_dt', 'qltwtr_begin_dt', 'qltwtr_end_dt', + 'rain_begin_dt', 'rain_end_dt', 'mesureDt', + // 🎯 camelCase 버전 추가 + 'instlYmd', 'lastDrdgnYmd', 'cmcnYmd', 'oprtngStrtYmd', 'clsgYmd', + 'mesureBeginDt', 'mesureEndDt', 'qltwtrBeginDt', 'qltwtrEndDt', + 'rainBeginDt', 'rainEndDt', + 'flfmtBgngYmd', 'flfmtEndYmd', 'flfmtYr' +]; + +// 🎯 연월 필드들 (yyyymm -> yyyy년 m월) +export const yearMonthFields = ['aplcnYm',]; + +// 🎯 날짜시간 필드들 (yyyy-mm-dd hh:mm:ss -> yyyy년 m월 d일 hh:mm) +export const dateTimeFields = ['regDt',]; + +// 🎯 숫자 필드들 (천 단위 구분자) +export const numberFields = [ + 'pdmt', 'prlg', 'avg_grdn', 'drtw_mltv_area', 'excln_mltv_area', 'tmpt_dpth', 'scend_dpth', 'trfln_cnt', 'vrtc_len', 'wdth_len', + 'fclt_cpct', 'plln_area', 'prcs_zone_area_sum', 'swtrmt_ppltn_cnt', 'stty_crtr_bod', 'stty_crtr_toc', 'stty_crtr_ss', 'stty_crtr_tn', 'stty_crtr_tph', 'stty_crtr_clbcl_cnt', + 'swage_mh_diam', 'wdth_len', 'vrtc_len', 'altd_vl', 'dpthlVl', + 'plln_area', 'daily_prcs_cpct', 'max_impnd_cpct', 'altd_vl', 'wtlv_vl', 'excln_wpmp_ablt', + 'worflw_wall_hgt', + 'swr_ppl_eqp_ar', 'swr_trt_popl', 'weir_hg', 'weir_lt', + 'precdng_dry', 'rain_fall', 'cntnc_time', 'rain_in', + 'mesure_intrvl', 'qltwtr_co', 'mesureQy', 'mesureBod', 'mesureToc', 'mesureLoad', + // 🎯 camelCase 버전 추가 + 'avgGrdn', 'drtwMltvArea', 'exclnMltvArea', 'tmptDpth', 'scendDpth', 'trflnCnt', 'vrtcLen', 'wdthLen', + 'fcltCpct', 'pllnArea', 'prcsZoneAreaSum', 'swtrmtPpltnCnt', 'sttyCltrBod', 'sttyCltrToc', 'sttyCltrSs', 'sttyCltrTn', 'sttyCltrTph', 'sttyCltrClbclCnt', + 'swageMhDiam', 'altdVl', 'dpthlVl', + 'dailyPrcsCpct', 'maxImpndCpct', 'wtlvVl', 'exclnWpmpAblt', + 'worflwWallHgt', + 'swrPplEqpAr', 'swrTrtPopl', 'weirHg', 'weirLt', + 'precdngDry', 'rainFall', 'cntncTime', 'rainIn', + 'mesureIntrvl', 'qltwtrCo', 'rnysnFvlc', 'clarFvlc', + 'bizAmt', 'count', + 'chmbr_xcnts', 'chmbr_ydnts' +]; diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx new file mode 100644 index 000000000..0e2ec3780 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx @@ -0,0 +1,163 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로) + * + * 📄 MsSwagePpl + * 하수관로 정보 컴포넌트 + * + * - 하수관로의 자체입력정보 표시 및 편집 기능 + * - 보수 및 갱신, 청소 및 준설, 점검 및 진단 데이터 조회 및 표시 + * - VerticalTable을 통한 입력 폼 렌더링 + * - InputSection을 통한 운영 데이터 관리 + * - 저장 버튼을 통한 데이터 저장 기능 + * + * 🔗 사용 API + * - 시설별 운영 정보 조회: GET /api/v1/bizInfo/bizByFclt (sewerage-service) + */ +'use client' +import { VerticalTable } from '@/components/ui/verticalTable'; +import React, { useState, useEffect } from 'react' +import InputSection from '../InputSection'; +import { handleInputChange } from '../common'; +import { getData } from "@/components/lib/api"; +import useStore from '@/store/useStore'; + +// 하수관로 +const inputColumn = [ + { id: "instlMbdyCd", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, + { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, + { id: "mltvZoneNm", title: "배수구역", type: "input" }, + { id: "mltvDtdvNm", title: "배수분구", type: "input" }, + { id: "lineCd", title: "지선/간선", type: "select", options: ["지선", "간선"] }, + { id: "floCd", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, + { id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] }, +]; + +type operationDataType = { year: string; type: string, bizInfoNm: string, bizType: string }; + +const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => { + const [formData, setFormData] = useState({}); + const [repairData, setRepairData] = useState([]); + const [cleaningData, setCleaningData] = useState([]); + const [inspectionData, setInspectionData] = useState([]); + const dataMap = { + repair: { data: repairData, setData: setRepairData }, + cleaning: { data: cleaningData, setData: setCleaningData }, + inspection: { data: inspectionData, setData: setInspectionData }, + }; + + const { BIZ_SE_CD, BIZ_TY_CD } = useStore(); + + // 마운트 및 업데이트 시 API 호출 + + useEffect(() => { + if (featureData) { + setFormData(featureData); + } + + const fetchAll = async () => { + const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${featureData?.mng_inst_cd}&fcltMngNo=${featureData?.fclt_mng_no}&page=0&size=1`); + + let inspectionArr: operationDataType[] = []; + let cleaningArr: operationDataType[] = []; + let repairArr: operationDataType[] = []; + + if (Array.isArray(operationData)) { + operationData.forEach(item => { + if (!item.flfmt_yr) return; + // biz_se_cd에 해당하는 type명 찾기 + const seCodeObj = BIZ_SE_CD.find(code => code.lwpoCd === item.biz_se_cd); + const tyCodeObj = BIZ_TY_CD.find(code => code.lwpoCd === item.biz_ty_cd); + const seTypeName = seCodeObj ? seCodeObj.lwpoCdNm : ""; + const tyTypeName = tyCodeObj ? tyCodeObj.lwpoCdNm : ""; + switch (item.biz_se_cd) { + case "100": + inspectionArr.push({ + year: String(item.flfmt_yr), + type: seTypeName, + bizInfoNm: String(item.biz_info_nm), + bizType: tyTypeName, + }); + break; + case "203": + cleaningArr.push({ + year: String(item.flfmt_yr), + type: seTypeName, + bizInfoNm: String(item.biz_info_nm), + bizType: tyTypeName, + }); + break; + case "200": + case "201": + case "204": + repairArr.push({ + year: String(item.flfmt_yr), + type: seTypeName, + bizInfoNm: String(item.biz_info_nm), + bizType: tyTypeName, + }); + break; + } + }); + } + + setInspectionData(inspectionArr); + setCleaningData(cleaningArr); + setRepairData(repairArr); + }; + fetchAll(); + }, [featureData]); + + // 폼 데이터가 변경될 때 호출될 핸들러 + const handleFormDataChange = (newFormData: any) => { + setFormData(newFormData); + }; + + // 저장 버튼 클릭 시 최신 formData를 부모에게 전달 + const handleSaveClick = () => { + if (onSaveClick) { + // 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달 + onSaveClick(); + } + }; + + + return ( + <> + + handleInputChange(section, index, field, value, dataMap)} + /> + handleInputChange(section, index, field, value, dataMap)} + /> + handleInputChange(section, index, field, value, dataMap)} + /> + + ) +} + +export default MsSwagePpl \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx new file mode 100644 index 000000000..f908fe98b --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/Ssc.tsx @@ -0,0 +1,358 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실) + * + * 📄 Ssc + * 우수토실 정보 컴포넌트 + * + * - 우수토실의 자체입력정보 표시 및 편집 기능 + * - 중점우수토실 운영정보 모달 표시 (강우/측정 정보, 수량/수질측정 정보) + * - VerticalTable을 통한 입력 폼 렌더링 + * - 저장 버튼을 통한 데이터 저장 기능 + * + * 🔗 사용 API + * - 위경도로 강우시 미처리 하수 목록 조회: 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'; +import { Button, TableHead, TableRow } from '@common/components/ui'; +import React, { useState, useEffect } from 'react'; +import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table'; +import { VerticalTable } from '@/components/ui/verticalTable'; +import { getData } from "@/components/lib/api"; +import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; +import { Label } from '@radix-ui/react-label'; + +const inputColumn = [ + { id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] }, + { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, +]; + +const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: { + featureData: any, + isMainSsc?: boolean, + chmbrXcnts?: string, + chmbrYdnts?: string, + onSaveClick?: () => void +}) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDataLoaded, setIsDataLoaded] = useState(false); + const [formData, setFormData] = useState({}); + const [rainMeasureData, setRainMeasureData] = useState({}); // 강우, 측정 정보 + const [mntrngsData, setMntrngsData] = useState([]); // 수량/수질측정 정보 + const [totalCount, setTotalCount] = useState(0); + const [totalDetailCount, setTotalDetailCount] = useState(0); + const [selectedRowIndex, setSelectedRowIndex] = useState(-1); + const [curPage, setCurPage] = useState(1); + const [curDetailPage, setCurDetailPage] = useState(1); + const [isResetPage, setIsResetPage] = useState(true); + const [isResetDetailPage, setIsResetDetailPage] = useState(true); + const [isResetSelection, setIsResetSelection] = useState(false); + + // 강우, 측정 정보 API 호출 함수 + const fetchRainMeasureData = async (chmbrXcnts: string, chmbrYdnts: string) => { + setIsResetDetailPage(true); + setIsResetSelection(true); + if (!chmbrXcnts || !chmbrYdnts) { + console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { chmbrXcnts, chmbrYdnts }); + return []; + } + + try { + const queryParams = new URLSearchParams({ + chmbrXcnts: chmbrXcnts, + chmbrYdnts: chmbrYdnts, + page: `${curPage - 1}`, + size: '3', + }); + + let result; + const res = await fetch(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`, { + method: "GET", + }); + if (res.ok) { + // 응답이 비어있는지 확인 + const contentLength = res.headers.get('content-length'); + if (contentLength === '0') result = null; + + // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 + const text = await res.text(); + if (!text || text.trim() === '') result = null; + + // JSON 파싱 시도 + result = JSON.parse(text); + } + + // helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM" + const formatNoYearNoSec = (val: any) => { + if (!val && val !== 0) return "-"; + const s = String(val); + // 정규식으로 안전하게 매칭 후 변환 + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/); + if (m) { + const [, , mm, dd, hh, min] = m; + return `${mm}-${dd} ${hh}:${min}`; + } + // 포맷이 예상과 다르면, 길이에 따라 잘라보기 + if (s.length >= 16) return s.substring(5, 16); // "YYYY-" 제거하고 초 전까지 + return s; + }; + + const processedDataArray: any[] = result.content.map((data: any) => { + // 🎯 formatDisplayData로 기본 포맷팅 적용 + const formattedItem = formatDisplayData(data); + const flwmtrInstTp = data.flwmtrInstTp === 'R' ? '이동식' : data.flwmtrInstTp === 'S' ? '고정식' : '-'; + const flwmtrMesureTp = data.flwmtrMesureTp === 'R' ? '레이더' : data.flwmtrMesureTp === 'P' ? '플륨' + : data.flwmtrMesureTp === 'U' ? '초음파' : data.flwmtrMesureTp === 'E' ? '기타' : '-'; + const flwmtrCombined = `${flwmtrInstTp}, ${flwmtrMesureTp}`; + // 🎯 추가 데이터 조합 + return { + ...formattedItem, + rainInfo: `${data.rainInfo === '0' ? '강우량계' : data.observatory || '-'}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용 + mesureIntrvl: `${data.mesureIntrvlGbn === '1' ? '1분' : (data.mesureIntrvlGbn === '0' ? data.mesureIntrvl + '분' : '-')}`, // 측정간격 + flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || '-'}`, // 유량계종류 및 모델명 + mesureBeginDt: data.mesureBeginDt ? formatNoYearNoSec(data.mesureBeginDt) : '-', // 수량 측정시작일 + mesureEndDt: data.mesureEndDt ? formatNoYearNoSec(data.mesureEndDt) : '-', // 수량 측정종료일 + qltwtrBeginDt: data.qltwtrBeginDt ? formatNoYearNoSec(data.qltwtrBeginDt) : '-', // 수질 측정시작일(채수시작일) + qltwtrEndDt: data.qltwtrEndDt ? formatNoYearNoSec(data.qltwtrEndDt) : '-', // 수질 측정종료일(채수종료일) + }; + }); + setRainMeasureData(processedDataArray); + setTotalCount(result?.totalElements || 0); + } catch (error) { + console.error('추가 데이터 로딩 실패:', error); + return []; + } + }; + + // 수량/수질측정 정보 API 호출 함수 + const fetchMntrngsData = async (adstDstrCd: string, manageNo: string) => { + setIsResetDetailPage(true); + if (!adstDstrCd || !manageNo) { + console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { adstDstrCd, manageNo }); + return []; + } + + try { + const queryParams = new URLSearchParams({ + page: `${curDetailPage - 1}`, + size: '3', + }); + + let result; + const res = await fetch(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`, { + method: "GET", + }); + if (res.ok) { + // 응답이 비어있는지 확인 + const contentLength = res.headers.get('content-length'); + if (contentLength === '0') result = null; + + // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 + const text = await res.text(); + if (!text || text.trim() === '') result = null; + + // JSON 파싱 시도 + result = JSON.parse(text); + } + + // 각 아이템 전처리 + const processedDataArray = result.content.map((data: any) => { + // 기본 포맷팅 적용 + const formattedItem = formatDisplayData(data); + return { + ...formattedItem, + }; + }); + + setMntrngsData(processedDataArray); + setTotalDetailCount(result?.totalElements || 0); + } catch (error) { + console.error('추가 데이터 로딩 실패:', error); + return []; + } + }; + + + // 🎯 추가 데이터는 별도 useEffect로 비동기 로딩 + useEffect(() => { + // 강우/측정 정보 데이터 호출 + if (isMainSsc && chmbrXcnts && chmbrYdnts) { + fetchRainMeasureData(chmbrXcnts, chmbrYdnts) + } + }, [chmbrXcnts, chmbrYdnts]); + + const openModal = () => { + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + if (!isDataLoaded) { + setTimeout(() => setIsDataLoaded(true), 100); + } + }; + + // featureData가 변경될 때 formData 초기화 + useEffect(() => { + if (featureData) { + setFormData(featureData); + setMntrngsData([]); + } + }, [featureData]); + + // featureData가 변경될 때 formData 초기화 + useEffect(() => { + // showTable이 true일 때만 fetch (최초 렌더 방지) + setIsResetDetailPage(false); + setSelectedRowIndex(-1); + if (chmbrXcnts && chmbrYdnts) fetchRainMeasureData(chmbrXcnts, chmbrYdnts); + }, [curPage]); + + // 페이지 변경 시마다 데이터 fetch + useEffect(() => { + if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1) fetchMntrngsData(rainMeasureData[selectedRowIndex].adstDstrCd, rainMeasureData[selectedRowIndex].manageNo); + }, [curDetailPage]); + + // 폼 데이터가 변경될 때 호출될 핸들러 + const handleFormDataChange = (newFormData: any) => { + setFormData(newFormData); + }; + + // 저장 버튼 클릭 시 최신 formData를 부모에게 전달 + const handleSaveClick = () => { + if (onSaveClick) { + // 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달 + onSaveClick(); + } + }; + + // 테이블 셀 클릭 핸들러 추가 + const handleCellClick = async (index: number | undefined, headId: string) => { + if (index !== undefined && rainMeasureData) { + await fetchMntrngsData(rainMeasureData[index].adstDstrCd, rainMeasureData[index].manageNo); + setSelectedRowIndex(index); + } + }; + + const renderModalBody = () => ( +
+
+ +
+ {/* 강우정보 테이블 */} + + + {/* 측정정보 테이블 */} + + {/* 커스텀 헤더 */} + + 수량 + 수질 + + + 측정시작일시 + 측정종료일시 + 측정간격(분) + 측정기기설치위치 + 유량계 종류 및 모델명 + 채수시작일시 + 채수종료일시 + 채수횟수 + + +
+ +
+ {/* 수량/수질측정 정보 테이블 */} + +
+ +
+
+ ) + + return ( +
+ {!isMainSsc && ( + + )} + + {isMainSsc && ( +
+
+

중점우수토실 운영정보

+
+ + + +
+ )} +
+ ) +} + +export default Ssc \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx new file mode 100644 index 000000000..8b029673f --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx @@ -0,0 +1,73 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설) + * + * 📄 SwtrmtFclt + * 하수처리시설 정보 컴포넌트 + * + * - 하수처리시설의 디지털 자료관 연계 정보 표시 + * - VerticalTable을 통한 테이블 렌더링 + * - 조회 버튼을 통한 모달 표시 및 ReportTab 연동 + */ +'use client' +import React, { useState } 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'; + +// 하수처리시설 +const headList = [ + { id: "title", title: "하수도정비 기본계획보고서" }, +]; + +const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + const ctpvNm = gisObject?.ctpv_nm; + const sggNm = gisObject?.sgg_nm; + + return ( +
+ setIsModalOpen(true)}>조회 + }} + headName='디지털 자료관 연계정보' + tableData={[]} + headlist={headList} + pageSize={20} + total={0} + cellClick={undefined} + isEditable={true} + onPageChange={function (page: number): void { + throw new Error("Function not implemented."); + }} + /> + + } + useDirectRender={true} + /> +
+ ) +} + +export default SwtrmtFclt \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts new file mode 100644 index 000000000..aba8d2eed --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts @@ -0,0 +1,271 @@ +import {useState, useEffect} from 'react'; +import { getData } from '@/components/lib/api'; +import moment from "moment"; + +export type TInftwtAnls = { + inftwtAnlsSn:number; + aplcnYm: string; + ctpvCd: string; + sggCd: string; + swtrmtFcltNm: string; + atflCd: string; + atflId: string; + atflInpNm: string; + atflHrPttnNm: string; + atflMpngNm: string; + atflOilqNm: string; + atflNm: string; + drtwSwtcRt:number; + anlsInfo: string; + scsYn: string; +}; +export type TInftwtAnlsRslt = { + inftwtAnlsSn: number; + pplMngNo: string; + anlsTm: string; + predcOilq:number; +} +export type TRdiiAnls = { + rainInfwtAnlsSn:number; + aplcnYm: string; + ctpvCd: string; + sggCd: string; + swtrmtFcltNm: string; + atflCd: string; + atflId: string; + atflInpNm: string; + atflHrPttnNm: string; + atflMpngNm: string; + atflRainNm: string; + atflNm: string; + drtwSwtcRt:number; + anlsInfo: string; + scsYn: string; + srcNm: string; // 출처이름 +}; +export type TRdiiAnlsRslt = { + rainInfwtAnlsSn: number; + pplMngNo: string; + anlsTm: string; + predcOilq:number; + minq:number; +} +export type TOilqData = { + inftwtAnlsSn: number; // 침입수분석 일련번호 + pplMngNo: string; // 관로관리번호 + mtrngDt: string; // 계측일시 + oilq: number; // 유량 +} +export type TRainData = { + rainInfwtAnlsSn: number; + mtrngDt: string; + amrf: number; // 강우량 +} + +function roundTo(n: number, digits: number): number { + const factor = Math.pow(10, digits); + return Math.round(n * factor) / factor; +} + +const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string, hidden?: boolean }[] => { + const yyyymms1 : { text: string; val: string; hidden?: boolean }[] = yyyymms.map(dateStr => { + const year = dateStr.slice(0, 4); + const month = dateStr.slice(4, 6); + return { + text: `${year}-${month}`, + val: dateStr + }; + }); + yyyymms1.unshift({ val: '0', text: '선택', hidden: true }); + return yyyymms1; +}; +const selectAplcnYms = async(gubun:string) => { // 상수사용월 + try { + const queryString = ''; + const result = await getData(`v1/${gubun}/aplcnYm${queryString ? `?${queryString}` : ''}`); + if (result && result.length > 0) { + return getOptionsAplcnYm(result); + } + } catch (error) { + console.error(error); + } +}; + +export const useAplcnYms = (gubun:string) => { + const [data, setData] = useState<{ text: string; val: string }[]>([]); + + useEffect(() => { + const fetch = async (gubun:string) => { + const result = await selectAplcnYms(gubun); + if (result) setData(result); + }; + fetch(gubun); + }, [gubun]); + + return data; +}; +export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number, hidden?: boolean }[] => { + const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({ + text: anls.anlsInfo, + val: anls.inftwtAnlsSn, + })); + options.unshift({ val: 0, text: '선택', hidden: true }); + return options; +}; + +export const selectInftwtAnls = async(aplcnYm:string) => { // 침입수분석 + try { + const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ''; + const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ''}`); + if (result && result.numberOfElements > 0) { + return result.content; + } + } catch (error) { + console.error(error); + } +}; +export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]): { label: string; val: number }[] => { + const options = arr.map(anlsRslt => ({ + label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("HH시"), + val: roundTo(anlsRslt.predcOilq, 2), + })); + return options; +}; +export const selectInftwtAnlsRslt = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수분석 결과 + try { + if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { + console.warn("inftwtAnlsSn or pplMngNo is not provided"); + return []; + } + const result = await getData(`v1/inftwtAnlsRslt/ppl/${inftwtAnlsSn}/${pplMngNo}`); + if (result && result.numberOfElements > 0) { + return result.content; + } + } catch (error) { + console.error(error); + } +}; +export const getChartDataOilq = (arr: TOilqData[]): { label: string; val: number }[] => { + const options = arr.map(anlsRslt => ({ + label: anlsRslt.mtrngDt, + val: roundTo(anlsRslt.oilq, 2), + })); + return options; +}; +export const selectOilq = async(inftwtAnlsSn:number, pplMngNo:string) => { // 유량 + try { + if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { + console.warn("inftwtAnlsSn or pplMngNo is not provided"); + return []; + } + const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`); + if (result && result.numberOfElements > 0) { + return result.content; + } + } catch (error) { + console.error(error); + } +}; +export const selectPplInftwtQty = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수량 + try { + if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { + console.warn("inftwtAnlsSn or pplMngNo is not provided"); + return []; + } + const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`); + if (result.length > 0) { + return result[0].avg_day_flow; + } else { + return 0; // 데이터가 없을 경우 0 반환 + } + } catch (error) { + console.error(error); + } +}; +//---------- +export const selectPplRdiiQty = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // rdii값 + try { + if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) { + console.warn("rainInfwtAnlsSn or pplMngNo is not provided"); + return []; + } + const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${pplMngNo}`); + if (result?.sustaintime > 0) { + return result; + } + } catch (error) { + console.error(error); + } +}; + +export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number, hidden?: boolean }[] => { + const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({ + text: anls.anlsInfo, + val: anls.rainInfwtAnlsSn, + })); + options.unshift({ val: 0, text: '선택', hidden: true }); + return options; +}; + +export const selectRdiiAnls = async(aplcnYm:string) => { // 침입수분석 + try { + const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ''; + const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ''}`); + if (result && result.numberOfElements > 0) { + return result.content; + } + } catch (error) { + console.error(error); + } +}; +export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]): { label: string; lineVal0: number, barVal0:number }[] => { + const options = arr.map(anlsRslt => ({ + label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"), + lineVal0: roundTo(anlsRslt.predcOilq, 2), + barVal0: 0 + })); + return options; +}; +export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]): { label: string; val: number }[] => { + const options = arr.map(anlsRslt => ({ + label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"), + val: roundTo(anlsRslt.minq, 2), + })); + return options; +}; +export const selectRdiiAnlsRslt = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // RDII분석 결과 + try { + if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) { + console.warn("RdiiAnlsSn or pplMngNo is not provided"); + return []; + } + const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${pplMngNo}`); + if (result && result.numberOfElements > 0) { + return result.content; + } + } catch (error) { + console.error(error); + } +}; +export const selectRainData = async(rainInfwtAnlsSn:number) => { // 강우데이터 + try { + if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0)) { + console.warn("RdiiAnlsSn or pplMngNo is not provided"); + return []; + } + const result = await getData(`v1/rdiiAnlsRslt/rainData/${rainInfwtAnlsSn}`); + if (result && result.numberOfElements > 0) { + return result.content; + } + } catch (error) { + console.error(error); + } +}; +export const getChartDataRainData = (arr: TRainData[]): { label: string; lineVal0: number, barVal0: number }[] => { + const options = arr.map(data => ({ + label: moment(data.mtrngDt, "YYYY-MM-DD HH:mm:ss").format("MM-DD HH시"), + lineVal0: 0, + barVal0: roundTo(data.amrf,2), + })); + return options; +}; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/infilChart.tsx new file mode 100644 index 000000000..3836b87f6 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/infilChart.tsx @@ -0,0 +1,297 @@ +'use client' + +import React, { useEffect, useMemo, useState } from 'react'; +import moment from 'moment'; + +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; // 관로관리번호 +}; + +type DataType = { + label: string; + val: number; +}; + +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([]); + const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>( + INITIAL_ANALYSIS_OPTIONS, + ); + const [inftwtAnls, setInftwtAnls] = useState(null); + const [selectedInftwtAnls, setSelectedInftwtAnls] = useState(0); + const [chartData, setChartData] = useState<{ chartData1: DataType[]; chartData2: DataType[] }>({ + chartData1: [], + chartData2: [], + }); + const [inftwtQty, setInftwtQty] = useState('0'); + + const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt'); + const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : ''; + + const chartForm = CHART_FORM.LINE; + const chartConfig = { + chartData1: { label: '순오수량', color: 'red' }, + chartData2: { label: '실측하수량', color: 'orange' }, + } as ChartConfig; + + const hasChartData = useMemo(() => { + return chartData.chartData1.length > 0 || chartData.chartData2.length > 0; + }, [chartData]); + + const resetState = () => { + setSelectedAplcnYm(''); + setInftwtAnlss([]); + setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + setInftwtAnls(null); + setSelectedInftwtAnls(0); + setChartData({ chartData1: [], chartData2: [] }); + setInftwtQty('0'); + }; + + useEffect(() => { + if (!isModalOpen) { + resetState(); // 모달이 닫힐 때 리셋 + } + }, [isModalOpen]); + + const handleAplcnYmChange = async (val: string | number) => { + const selectedValue = String(val); + + setSelectedAplcnYm(selectedValue); + setInftwtAnlss([]); + setInftwtAnls(null); + setSelectedInftwtAnls(0); + setInftwtQty('0'); + setChartData({ chartData1: [], chartData2: [] }); + + if (!selectedValue || selectedValue === '0') { + setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + return; + } + + try { + const data = await fn.selectInftwtAnls(selectedValue); + const list = data ?? []; + setInftwtAnlss(list); + setInftwtAnlsInfos(fn.getOptionsInftwtAnlsInfo(list)); + } catch (error) { + console.error('selectInftwtAnls 에러:', error); + setInftwtAnlss([]); + setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + } + }; + + const handleInftwtAnlsSnChange = (val: string | number) => { + const selectedValue = Number(val); + + setSelectedInftwtAnls(selectedValue); + setChartData({ chartData1: [], chartData2: [] }); + setInftwtQty('0'); + + if (selectedValue === 0) { + setInftwtAnls(null); + return; + } + + const selectedAnls = inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null; + setInftwtAnls(selectedAnls); + }; + + const infilRsltChart = async () => { + if (!selectedAplcnYm || selectedInftwtAnls === 0 || !normalizedPplMngNo) { + setChartData({ chartData1: [], chartData2: [] }); + setInftwtQty('0'); + return; + } + + try { + const [inftwtAnlsRslt, oilq, qty] = await Promise.all([ + fn.selectInftwtAnlsRslt(selectedInftwtAnls, normalizedPplMngNo), + fn.selectOilq(selectedInftwtAnls, normalizedPplMngNo).catch((err) => { + console.error('selectOilq 에러:', err); + return []; + }), + fn.selectPplInftwtQty(selectedInftwtAnls, normalizedPplMngNo).catch((err) => { + console.error('selectPplInftwtQty 에러:', err); + return 0; + }), + ]); + + setChartData({ + chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []), + chartData2: fn.getChartDataOilq(oilq ?? []), + }); + + const normalizedQty = Number(qty ?? 0); + setInftwtQty(normalizedQty.toLocaleString('ko-KR')); + } catch (error) { + console.error('infilRsltChart 에러:', error); + setChartData({ chartData1: [], chartData2: [] }); + setInftwtQty('0'); + } + }; + + const renderInfoInput = (label: string, value?: string | number | null) => ( +
+ + +
+ ); + + const handleReset = () => { + resetState(); + } + + const renderBody = () => ( +
+
+
+
+ +
+
+

+ 침입수 분석 선택 조건 +

+ +
+
+
+ + +
+ +
+ + +
+ + {renderInfoInput('오수전환율:', inftwtAnls?.drtwSwtcRt ?? '')} + {renderInfoInput('시간패턴 파일명:', inftwtAnls?.atflHrPttnNm ?? '')} + {renderInfoInput('수용가-맨홀 매칭:', inftwtAnls?.atflMpngNm ?? '')} + {renderInfoInput('관망도 파일명:', inftwtAnls?.atflInpNm ?? '')} + {renderInfoInput('유량 파일명:', inftwtAnls?.atflOilqNm ?? '')} +
+
+ +
+    + +
+
+ +
+

침입수 분석 결과

+ +
+
+
+ {/* ● */} + 상수사용량 적용연월 +
+
+ {selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'} +
+
+ +
+
+ {/* ● */} + 침입수량 산정결과 +
+
+ 침입수량 +
+ {inftwtQty} + m³/d +
+
+
+
+ +
+ {hasChartData ? ( + + ) : ( +
+ 데이터가 없습니다. +
+ )} +
+
+
+
+ ); + + return ( + + ); +}; + +export default React.memo(InfilChart); diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx new file mode 100644 index 000000000..aa622be1b --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx @@ -0,0 +1,357 @@ +'use client' + +import React, { useEffect, useMemo, useState } from 'react'; +import moment from 'moment'; + +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; // 관로관리번호 +}; + +type LineBarChartData = { + label: string; + lineVal0: number; + barVal0: number; +}; + +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([]); + const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>( + INITIAL_ANALYSIS_OPTIONS, + ); + const [rdiiAnls, setRdiiAnls] = useState(null); // 강우유입수분석정보 + const [rainInfwtAnlsSn, setRainInfwtAnlsSn] = useState(0);// 강우유입수분석 정보 선택시 사용 + const [chartData, setChartData] = useState([]); + const [rdiiQty, setRdiiQty] = useState('0'); // 강우유입수량 + const [rainTotal, setRainTotal] = useState('0'); // 적용강우량 + const [totalVolume, setTotalVolume] = useState('0');// 총체적 m³/mm + + const aplcnYms = fn.useAplcnYms('rdiiAnlsRslt'); // 상수사용월 데이터 가져오기 + const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : ''; + + const chartForm = CHART_FORM.LINEBAR2; + const chartConfig = { + barVal0: { + label: '강수량', + color: '#33AAFF', + }, + lineVal0: { + label: 'RDII', + color: '#FF0000', + }, + } as ChartConfig; + + const hasChartData = useMemo(() => chartData.length > 0, [chartData]); + + const resetState = () => { + setAplcnYm(''); // 상수사용월 초기화 + setRdiiAnlss([]); // 강우유입수분석 초기화 + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); // 강우유입수분석정보 초기화 + setRdiiAnls(null); // 강우유입수분석정보 초기화 + setRainInfwtAnlsSn(0); // 강우유입수분석 일련번호 초기화 + setChartData([]); + setRdiiQty('0'); // 강우유입수량 초기화 + setRainTotal('0');// 적용강우량 초기화 + setTotalVolume('0');// 총체적 m³/mm 초기화 + }; + + useEffect(() => { + if (!isModalOpen) { + resetState(); + } + }, [isModalOpen]); + + const handleAplcnYmChange = async (val: string | number) => { + const selectedValue = String(val); + + setAplcnYm(selectedValue); + setRdiiAnlss([]); + setRdiiAnls(null); + setRainInfwtAnlsSn(0); + setChartData([]); + setRdiiQty('0'); + setRainTotal('0'); + setTotalVolume('0'); + + if (!selectedValue || selectedValue === '0') { + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + return; + } + + try { + const data = await fn.selectRdiiAnls(selectedValue); // 상수사용월 변경시 분석 정보 리스트 가져오기 + const list = data ?? []; + setRdiiAnlss(list); + setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list)); + } catch (error) { + console.error('selectRdiiAnls 에러:', error); + setRdiiAnlss([]); + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + } + }; + + const handleRdiiAnlsSnChange = (val: string | number) => { + const selectedValue = Number(val); + + setRainInfwtAnlsSn(selectedValue); + setChartData([]); + setRdiiQty('0'); + setRainTotal('0'); + setTotalVolume('0'); + + if (selectedValue === 0) { + setRdiiAnls(null); + return; + } + + const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null; + setRdiiAnls(selectedAnls); + }; + + const fetchFilteredData = async () => { + if (!rainInfwtAnlsSn || !normalizedPplMngNo) { + setChartData([]); + setRdiiQty('0'); + setRainTotal('0'); + setTotalVolume('0'); + return; + } + + try { + const [rsltData, rainData, qtyData] = await Promise.all([ + fn.selectRdiiAnlsRslt(rainInfwtAnlsSn, normalizedPplMngNo), + fn.selectRainData(rainInfwtAnlsSn), + fn.selectPplRdiiQty(rainInfwtAnlsSn, normalizedPplMngNo), + ]); + + const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []); + const rainDataFormatted = fn.getChartDataRainData(rainData ?? []); + const rainMap = new Map(rainDataFormatted.map((item) => [item.label, item.barVal0])); + + rsltDataFormatted.forEach((item) => { + const matchedVal = rainMap.get(item.label); + if (matchedVal !== undefined) { + item.barVal0 = matchedVal; + } + }); + + setChartData(rsltDataFormatted);// 차트 데이터 설정 + + const avgRdii = Number(qtyData?.rdiih ?? 0); + const appliedRainfall = Number(qtyData?.raintotal ?? 0); + const rdiiPerRainfall = + qtyData?.raintotal && qtyData?.raintotal !== 0 + ? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal) + : 0; + + setRdiiQty(Math.round(avgRdii * 24).toLocaleString('ko-KR')); + setRainTotal(appliedRainfall.toLocaleString('ko-KR')); + setTotalVolume(rdiiPerRainfall.toLocaleString('ko-KR')); + } catch (error) { + console.error('rdiiRsltChart 에러:', error); + setChartData([]); + setRdiiQty('0'); + setRainTotal('0'); + setTotalVolume('0'); + } + }; + + const renderInfoInput = (label: string, value?: string | number | null) => ( +
+ + +
+ ); + + const renderBody = () => ( +
+
+
+
+ +
+
+

+ 강우유입수분석 선택 조건 +

+ +
+
+
+ + +
+ +
+ + +
+ + {renderInfoInput('오수전환율:', rdiiAnls?.drtwSwtcRt ?? '')} + {renderInfoInput('수용가-맨홀 매칭:', rdiiAnls?.atflMpngNm ?? '')} + {renderInfoInput('관망도 파일명:', rdiiAnls?.atflInpNm ?? '')} + {renderInfoInput('강우 파일명:', rdiiAnls?.atflRainNm ?? '')} + {renderInfoInput('RTK계수 출처:', rdiiAnls?.srcNm ?? '')} +
+
+ +
+ +    + +
+
+ +
+

+ 강우유입수분석 결과 +

+ +
+ {/* 좌측 카드 */} +
+
+ 상수사용량 적용연월 +
+
+ {aplcnYm ? moment(aplcnYm, 'YYYYMM').format('YYYY-MM') : '-'} +
+
+ + {/* 우측 카드 */} +
+ {/* 좌측: 제목 (세로 가운데 정렬) */} +
+
+ 강우유입수량 분석결과 +
+
+ + {/* 우측: 지표 영역 */} +
+
+
+ 평균RDII +
+
+ {rdiiQty} +
+
+ m³/d +
+
+ +
+
+ 적용 강우량 +
+
+ {rainTotal} +
+
+ mm +
+
+ +
+
+ 강우량 대비 RDII +
+
+ {totalVolume} +
+
+ m³/mm +
+
+
+
+
+ +
+ {hasChartData ? ( + + ) : ( +
+ 데이터가 없습니다. +
+ )} +
+
+
+
+ ); + + return ( + + ); +}; + +export default React.memo(RdiiChart); diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/chartModalStore.ts b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chartModalStore.ts new file mode 100644 index 000000000..c84fa48a7 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/chartModalStore.ts @@ -0,0 +1,16 @@ +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/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx new file mode 100644 index 000000000..11e61b006 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -0,0 +1,639 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 + * + * 📄 FeatureClickEventPopup + * 시설물(피처) 클릭시 오른쪽 팝업창 컴포넌트 + * + * - 지도 시설물(피처) 클릭 시 팝업 표시 및 데이터 로딩 + * - 레이어 유형에 따른 동적 테이블 및 헤드리스트 설정 + * - 자체입력정보 조회 및 저장 기능 (하수관로, 우수토실, 하수펌프장, 하수맨홀) + * - 코드 매핑 및 데이터 포맷팅 처리 + * - 상류관로 검색, 침입수/RDII 분석 차트 표시 + * - 모달을 통한 저장 확인 및 API 연동 + * + * 🔗 사용 API + * - 하수관로 자체입력정보 조회: GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd} (sewerage-service) + * - 우수토실 자체입력정보 조회: GET /api/v1/fcltstofcbr/{fcltMngNo}/{mngInstCd} (sewerage-service) + * - 하수펌프장 자체입력정보 조회: GET /api/v1/fcltpump/{fcltMngNo}/{mngInstCd} (sewerage-service) + * - 하수맨홀 자체입력정보 조회: GET /api/v1/fcltmhl/{fcltMngNo}/{mngInstCd} (sewerage-service) + * - 하수관로 자체입력정보 저장: POST /api/v1/fcltppl (sewerage-service) + * - 우수토실 자체입력정보 저장: POST /api/v1/fcltstofcbr (sewerage-service) + * - 하수펌프장 자체입력정보 저장: POST /api/v1/fcltpump (sewerage-service) + * - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltmhl (sewerage-service) + */ +'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"; +import { useEffect, useRef, useState, useMemo } from "react"; +import Style from "ol/style/Style.js"; +import Fill from "ol/style/Fill.js"; +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"; + +const linkedFacilityHighlightStyle = new Style({ + image: new CircleStyle({ + radius: 18, + fill: new Fill({ + color: 'rgba(255, 235, 59, 0.35)', + }), + stroke: new Stroke({ + color: 'rgba(255, 193, 7, 0.45)', + width: 2, + }), + }), + fill: new Fill({ + color: 'rgba(255, 235, 59, 0.2)', + }), + stroke: new Stroke({ + color: 'rgba(255, 193, 7, 0.35)', + width: 2.5, + }), + 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(""); // 레이어 이름 상태 + const [layerSpecificNm, setLayerSpecificNm] = useState(""); // 레이어 이름 상태 + const [tableData, setTableData] = useState(null); // 피처 데이터 + const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 + const [headlist2, setHeadlist2] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태 + 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(''); // 로딩 상태 + const [chmbrYdnts, setChmbrYdnts] = useState(''); // 로딩 상태 + const linkedFacilityFeatureRef = useRef(null); + const linkedFacilityOriginalStyleRef = useRef(null); + + // 🎯 관로관리번호 추출 + const pplMngNo = featureData?.values_?.fclt_mng_no || ""; + + const featureId = typeof featureData.getId === 'function' + ? featureData.getId() + : featureData.fid; + + // 🎯 데이터 전처리 메모이제이션 + const processedData = useMemo(() => { + if (!featureData?.values_) return null; + + return { + ...featureData.values_, + discharge_pipe_spec: `직경: ${featureData.values_.pipe_diameter || '-'} / 높이: ${featureData.values_.pipe_hg || '-'} / 길이: ${featureData.values_.pipe_lt || '-'}` + }; + }, [featureData?.values_]); + + const clearLinkedFacilityHighlight = () => { + if (linkedFacilityFeatureRef.current) { + linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined); + linkedFacilityFeatureRef.current.changed?.(); + } + + linkedFacilityFeatureRef.current = null; + linkedFacilityOriginalStyleRef.current = null; + }; + + // 하수처리구역에 연결된 하수처리시설 피처를 찾아 기존 아이콘 위에 노랑 하이라이트를 덧씌운다. + const highlightLinkedFacility = (facilityCode: string | null | undefined) => { + clearLinkedFacilityHighlight(); + + if (!gisMap || !facilityCode) return; + + const normalizedFacilityCode = String(facilityCode).trim(); + const layers = gisMap.getLayers().getArray(); + + for (const layer of layers) { + const source = layer?.getSource?.(); + if (!source?.getFeatures) continue; + + const features = source.getFeatures(); + const targetFeature = features.find((feature: any) => { + const featureId = feature?.getId?.(); + const isFacilityLayer = typeof featureId === 'string' && featureId.startsWith('tb_il_swage_prcs_fclt.'); + const featureFacilityCode = feature?.get?.('swtrmt_fclt_cd'); + + return isFacilityLayer && String(featureFacilityCode ?? '').trim() === normalizedFacilityCode; + }); + + if (targetFeature) { + const originalFeatureStyle = targetFeature.getStyle?.(); + const featureLayer = targetFeature.getLayer?.(); + const layerStyleFn = featureLayer?.getStyleFunction?.() ?? null; + + linkedFacilityOriginalStyleRef.current = originalFeatureStyle; + linkedFacilityFeatureRef.current = targetFeature; + targetFeature.setStyle((feature: any, resolution: number) => { + let styles: any[] = []; + + if (typeof originalFeatureStyle === 'function') { + const originalStyles = originalFeatureStyle(feature, resolution); + if (Array.isArray(originalStyles)) styles.push(...originalStyles); + else if (originalStyles) styles.push(originalStyles); + } else if (originalFeatureStyle) { + if (Array.isArray(originalFeatureStyle)) styles.push(...originalFeatureStyle); + else styles.push(originalFeatureStyle); + } else if (layerStyleFn) { + const layerStyles = layerStyleFn(feature, resolution); + if (Array.isArray(layerStyles)) styles.push(...layerStyles); + else if (layerStyles) styles.push(layerStyles); + } + + styles.push(linkedFacilityHighlightStyle); + return styles; + }); + targetFeature.changed?.(); + return; + } + } + }; + + // 피쳐데이터의 현재 레이어 이름 가져오는 함수 + const getSelectedLayers = () => { + let result = null; + const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 + let featureLayer: any = null; // 피처가 속한 레이어를 찾기 위한 변수 + layers.some((layer: any) => { + const source = layer.getSource(); + // if (source && featureData && featureData.fid) { + // featureLayer = layer; // 피처가 속한 레이어 찾기 + // } + if (source && source.hasFeature && featureData + && typeof featureData.getId === 'function' && source.hasFeature(featureData)) { + featureLayer = layer; // 피처가 속한 레이어 찾기 + return true; // 반복 종료 + } + return false; + }); + + if (featureData['ol_uid']) { + // Gis 레이어 클릭 이벤트 + setIsClicked(true); + const lyrTyNm = featureData.id_.split('.')[0]; + + switch (lyrTyNm) { + case 'tb_ms_swage_ppl': { + result = '하수관로'; + setHeadlist(MsSwagePplCol); + setLayerSpecificNm(featureData.values_.fclt_mng_no); + break; + } + case 'tb_ms_swage_mhl': { + result = '하수맨홀'; + setHeadlist(MsSwageMhlCol); + setLayerSpecificNm(featureData.values_.fclt_mng_no); + break; + } + case 'tb_ms_swage_pmstn': { + const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(code => code.text === '오수펌프장'); + if (drtwPumpCode && featureData.values_.usg_cd.trim() === drtwPumpCode.val) { + setIsDrtwSewagePump(true); + setHeadlist(DrtwSewagePumpCol); + } + else { + setIsDrtwSewagePump(false); + setHeadlist(ExclnSewagePumpCol); + } + setLayerSpecificNm(featureData.values_.fclt_mng_no); + result = '하수펌프장'; + break; + } + case 'tb_il_swage_prcs_fclt': { + result = '하수처리시설'; + setHeadlist(SwtrmtFcltCol); + setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd); + break; + } + case 'tb_ms_swage_prcs_zone': { + result = '하수처리구역'; + setHeadlist(SwtrmtPrcsZoneCol); + setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd); + break; + } + case 'tb_ms_stofcbr': { + result = '우수토실'; + setHeadlist(stofcbrCol); + setLayerSpecificNm(featureData.values_.fclt_mng_no); + break; + } + case 'om_dt_untrt_swg_rain': { + result = '중점우수토실'; + setHeadlist(untrtSwgRainCol1); + setHeadlist2(untrtSwgRainCol2); + setLayerSpecificNm(featureData.values_.manage_no); + break; + } + default: { + result = featureLayer ? featureLayer.get('lyrNm') : null; + break; + } + } + } + else { + // SearchModal 클릭 이벤트 + setIsClicked(false); + const lyrTyNm = featureData.lyrTypeNm; + + switch (lyrTyNm) { + case 'tb_ms_swage_ppl': { + result = '하수관로'; + setHeadlist(MsSwagePplCol2) + setLayerSpecificNm(featureData.swtrmtFcltCd); + break; + } + case 'tb_ms_swage_mhl': { + result = '하수맨홀'; + setHeadlist(MsSwageMhlCol2) + setLayerSpecificNm(featureData.fcltMngNo); + break; + } + case 'tb_ms_swage_pmstn': { + const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(code => code.text === '오수펌프장'); + if (drtwPumpCode && featureData.usgCd.trim() === drtwPumpCode.val) { + setIsDrtwSewagePump(false); + setHeadlist(ExclnSewagePumpCol2) + } + else { + setIsDrtwSewagePump(true); + setHeadlist(DrtwSewagePumpCol2) + } + setLayerSpecificNm(featureData.fcltMngNo); + result = '하수펌프장'; + break; + } + case 'tb_il_swage_prcs_fclt': { + result = '하수처리시설'; + setHeadlist(SwtrmtFcltCol2) + setLayerSpecificNm(featureData.swtrmtFcltCd); + break; + } + case 'tb_ms_swage_prcs_zone': { + result = '하수처리구역'; + setHeadlist(SwtrmtPrcsZoneCol2); + setLayerSpecificNm(featureData.swtrmtFcltCd); + break; + } + case 'tb_ms_stofcbr': { + result = '우수토실'; + setHeadlist(stofcbrCol2) + setLayerSpecificNm(featureData.fcltMngNo); + break; + } + } + } + + return result; + }; + + useEffect(() => { + const fetchData = async () => { + if (!featureId) return; + + try { + const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 + setLayerNm(selectedLayerName); // 상태 업데이트 + + let data; + if (featureData['ol_uid']) data = featureData.values_; // Gis 레이어 클릭 이벤트일 때 + else data = featureData; // SearchModal 클릭 이벤트일 때 + + // 하수맨홀인 경우 용도코드를 텍스트로 변환 + if (selectedLayerName === '하수맨홀' && data) { + // mqlt_cd 값 변환 (재질코드) + if (data.mqlt_cd) { + const materialCodeItem = MS_SWAGE_MHL_CODES.find( + code => code.val === data.mqlt_cd.trim() + ); + if (materialCodeItem) data.mqlt_cd = `${materialCodeItem.text}`; + } + } else { + // 중점우수토실인 경우 구분 값 + if (selectedLayerName === '중점우수토실' && data) { + if (data.mesure_spot_se) { + const mesureSpotSe = [{id:'0', text: '합류식'}, {id:'1', text: '분류식'}] + const mesureSpotSeItem = mesureSpotSe.find( + code => code.id === data.mesure_spot_se.trim() + ); + if (mesureSpotSeItem) data.mesure_spot_se = mesureSpotSeItem.text; + } + } + } + + // 🎯 기본 데이터 포맷팅 + const formattedData = formatDisplayData(data); + + setFetchData(formattedData); + setTableData(formattedData); + + switch (selectedLayerName) { + case '하수관로': + setButtonChk(true) + + // ✅ codeMapper 사용하여 API 결과도 변환 + setTableData(codeMapper({ + layerName: selectedLayerName, + data: { ...formattedData } + })); + break; + case '하수맨홀': + setButtonChk(true) + + // ✅ codeMapper 사용하여 API 결과도 변환 + setTableData(codeMapper({ + layerName: selectedLayerName, + data: { ...formattedData } + })); + break; + case '하수펌프장': + setButtonChk(true) + + setTableData(codeMapper({ + layerName: selectedLayerName, + data: { ...formattedData } + })); + break; + case '하수처리시설': + setButtonChk(false) + break; + case '하수처리구역': + setButtonChk(false) + break; + case '우수토실': + setButtonChk(true) + setIsMainSsc(false) + break; + case '중점우수토실': + setButtonChk(false) + setIsMainSsc(true) + // 🎯 processedData도 포맷팅하여 설정 + const processedFormattedData = formatDisplayData(processedData); + + setFetchData([processedFormattedData]) + setTableData(processedFormattedData); + // setIsLoading(false); // 기본 렌더링 완료 + + setTableData(codeMapper({ + layerName: selectedLayerName, + data: { ...processedFormattedData } + })); + + setChmbrXcnts(data.chmbr_xcnts); + setChmbrYdnts(data.chmbr_ydnts); + + break; + default: + break; + } + + // 자체입력정보 API 엔드포인트 선택 + let apiEndpoint = ''; + const fcltMngNo = featureData?.values_?.fclt_mng_no || null; + const mngInstCd = featureData?.values_?.mng_inst_cd || null; + if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`; + else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`; + else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`; + else if (selectedLayerName === '하수맨홀') apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`; + + // API에서 상세 데이터 가져오기 + if (apiEndpoint) { + try { + const result = await getData(apiEndpoint); + + if (result) { + const processedApiData = codeMapper({ + layerName: selectedLayerName, + data: { ...result, ...tableData } + }); + + setFetchData(processedApiData); + } + } catch (apiError) { + console.error('API 데이터 로딩 중 오류:', apiError); + } + } + } catch (error) { + console.error('데이터 로딩 중 오류:', error); + } + }; + + fetchData(); + }, [featureId, processedData]); + + useEffect(() => { + const layerTypeNm = featureData?.values_ + ? featureData?.id_?.split('.')?.[0] + : featureData?.lyrTypeNm; + + if (layerTypeNm === 'tb_ms_swage_prcs_zone') { + const facilityCode = featureData?.values_?.swtrmt_fclt_cd ?? featureData?.swtrmtFcltCd; + highlightLinkedFacility(facilityCode); + return () => { + clearLinkedFacilityHighlight(); + }; + } + + clearLinkedFacilityHighlight(); + + return () => { + clearLinkedFacilityHighlight(); + }; + }, [featureData, gisMap]); + + // 저장 시 모달 띄우기 + const handleSaveClick = () => { + setIsModalOpen(true); // 모달 열기 + }; + + const handleModalSave = () => { + setIsModalOpen(false); // 모달 닫기 + btnClickEvent(); // 저장 이벤트 실행 + }; + + const handleModalClose = () => { + setIsModalOpen(false); // 모달 닫기 + }; + + // 저장 버튼 이벤트 + const btnClickEvent = async () => { + const bodyData: Record = {} + + if (buttonChk && contentRef.current) { + const inputElements = contentRef.current.querySelectorAll('input'); + const selectElements = contentRef.current.querySelectorAll('select'); + + // 카멜케이스 변환 함수 + const toCamelCase = (str: string) => + str.replace(/_([a-z])/g, (_, char) => char.toUpperCase()); + + // select 태그의 값 가져오기 + selectElements.forEach((select) => { + const camelCaseId = toCamelCase(select.id); + bodyData[camelCaseId] = select.value; + }); + + // // input 태그의 값 가져오기 + inputElements.forEach((input) => { + const camelCaseId = toCamelCase(input.id); + bodyData[camelCaseId] = input.value; + }); + + // 레이어명에 따라 API 엔드포인트 분기처리 + let apiEndpoint = ''; + let result = null; + + if (layerNm === '하수관로') apiEndpoint = 'v1/fcltppl'; + else if (layerNm === '우수토실') apiEndpoint = 'v1/fcltstofcbr'; + else if (layerNm === '하수펌프장') apiEndpoint = 'v1/fcltpump'; + else if (layerNm === '하수맨홀') apiEndpoint = 'v1/fcltmhl'; + + bodyData['fcltMngNo'] = featureData.values_.fclt_mng_no.toString() || null; + bodyData['mngInstCd'] = featureData.values_.mng_inst_cd.toString() || null; + // 빈 키 제거 + if (bodyData[""] !== undefined) delete bodyData[""]; + + // 🎯 우수토실일 경우 prrtymngYn 값 변환 + if (layerNm === '우수토실' && bodyData.prrtymngYn) { + if (bodyData.prrtymngYn === '중점우수토실') { + bodyData.prrtymngYn = 'Y'; + } else if (bodyData.prrtymngYn === '우수토실') { + bodyData.prrtymngYn = 'N'; + } + } + + try { + result = await postData(apiEndpoint, bodyData); + + if (result) { + alert('저장되었습니다'); + setFetchData(result.data || result); + } else { + alert('저장에 실패했습니다'); + console.error('API 응답 오류:', result); + } + } catch (error) { + alert('저장 중 오류가 발생했습니다'); + console.error('API 요청 오류:', error); + } + } + } + + // 레이어명에 따라 상세페이지 렌더링 + const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null; + + return ( + + ) +} +export default FeatureClickEventPopup; diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx new file mode 100644 index 000000000..07d78a7d1 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/sewagePump.tsx @@ -0,0 +1,69 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수펌프장) + * + * 📄 sewagePump + * 하수펌프장 정보 컴포넌트 + * + * - 빗물펌프장 및 오수펌프장 구분에 따른 자체 입력 정보 표시 및 편집 기능 + * - VerticalTable을 통한 입력 폼 렌더링 + * - 저장 버튼을 통한 데이터 저장 기능 + */ +import { VerticalTable } from '@/components/ui/verticalTable'; +import React, { useState, useEffect } from 'react' +// 빗물펌프장 자체 입력 정보 +const exclnInputColumn = [ + { id: "swtrmtZoneNm", title: "배수구역", type: "input" }, + { id: "mltvDtdvNm", title: "배수분구", type: "input" }, + { id: "designFrqYr", title: "설계빈도(년)", type: "input", placeholder: "예: 10", isNumber: true, length: 4 }, +] +// 오수펌프장 자체 입력 정보 +const drtwInputColumn = [ + { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, + { id: "mltvDtdvNm", title: "처리분구", type: "input" }, +] + + +const sewagePump = ({ featureData, isDrtwSewagePump, onSaveClick }: { featureData: any, isDrtwSewagePump?: boolean, onSaveClick?: () => void }) => { + const [formData, setFormData] = useState({}); + + // featureData가 변경될 때 formData 초기화 + useEffect(() => { + if (featureData) { + setFormData(featureData); + } + }, [featureData]); + + // 폼 데이터가 변경될 때 호출될 핸들러 + const handleFormDataChange = (newFormData: any) => { + setFormData(newFormData); + }; + + // 저장 버튼 클릭 시 최신 formData를 부모에게 전달 + const handleSaveClick = () => { + if (onSaveClick) { + // 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달 + onSaveClick(); + } + }; + + return ( + + ) +} + +export default sewagePump diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx new file mode 100644 index 000000000..2a0b11682 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/smh.tsx @@ -0,0 +1,146 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀) + * + * 📄 Smh + * 하수맨홀 정보 컴포넌트 + * + * - 하수맨홀의 자체입력정보 표시 및 편집 기능 + * - VerticalTable을 통한 입력 폼 렌더링 + * - 상류관로 검색 및 지도 포커스 기능 + * - 저장 버튼을 통한 데이터 저장 기능 + * + * 🔗 사용 API + * - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service) + */ +import { VerticalTable } from '@/components/ui/verticalTable'; +import { useEffect, useState } from 'react'; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; + +// 상류관로 보기 함수 (FeatureClickEventPopup에서 사용) +export async function searchUpperPpls(gisMap: any, fcltMngNo: any) { + const BASE_URL = "/sewerage-service/api"; + const res = await fetch(`${BASE_URL}/v1/fcltmhl/upperPpls/${fcltMngNo}`, { + method: "GET", + }); + + let data: any = null; + if (res.ok) { + try { + // 응답이 비어있는지 확인 + const contentLength = res.headers.get("content-length"); + if (contentLength === "0") { + alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); + return; + } + + // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 + const text = await res.text(); + if (!text || text.trim() === "") { + alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); + return; + } + + // JSON 파싱 시도 + const response = JSON.parse(text); + data = response.data; + } catch (error) { + console.error("JSON parsing error:", error); + alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); + return; + } + } else { + alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); + return; + } + + const select: any = GisApp.Module.select; + let foundFeature: any = null; + + (data || []).forEach((item: any) => { + const pplMngNo = item.ppl_mng_no?.trim?.(); + + gisMap + .getLayers() + .getArray() + .filter((e: any) => e.get("lyrTyNm") == "tb_ms_swage_ppl") + .forEach((layer: any) => { + const source = layer.getSource?.(); + if (source?.getFeatures) { + const features = source.getFeatures(); + for (const f of features) { + if (f.values_.fclt_mng_no?.trim?.() === pplMngNo) { + foundFeature = f; + break; + } + } + } + }); + + if (foundFeature) select?.selectMultiFeature?.(foundFeature, true); + }); + + if (!foundFeature) alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`); + + const geometry = foundFeature?.getGeometry(); + if (geometry) { + const extent = geometry.getExtent(); + gisMap.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 14, + constrainResolution: true, + nearest: true, + }); + } +} + +const inputColumn = [ + { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, + { id: "mltvZoneNm", title: "배수구역", type: "input" }, + { id: "mltvDtdvNm", title: "배수분구", type: "input" }, + { id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] }, +]; +const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => { + const [formData, setFormData] = useState({}); + + // featureData가 변경될 때 formData 초기화 + useEffect(() => { + if (featureData) { + setFormData(featureData); + } + }, [featureData]); + + // 폼 데이터가 변경될 때 호출될 핸들러 + const handleFormDataChange = (newFormData: any) => { + setFormData(newFormData); + }; + + // 저장 버튼 클릭 시 최신 formData를 부모에게 전달 + const handleSaveClick = () => { + if (onSaveClick) { + // 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달 + onSaveClick(); + } + }; + + return ( + + ) +} + +export default Smh; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx new file mode 100644 index 000000000..92973b133 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/stormWaterPump.tsx @@ -0,0 +1,37 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (빗물펌프) + * + * 📄 StormWaterPump + * 빗물펌프 정보 컴포넌트 + * + * - 빗물펌프의 자체입력정보 표시 및 편집 기능 + * - VerticalTable을 통한 입력 폼 렌더링 + */ +import { VerticalTable } from '@/components/ui/verticalTable'; +import React from 'react' +const inputColumn = [ + { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, + { id: "mltvDtdvNm", title: "배수분구", type: "input" }, +] +// 빗물펌프 +const StormWaterPump = ({ featureData }: { featureData: Record }) => { + const validKey = inputColumn.map((item) => item.id); + const filteredData = featureData && Object.entries(featureData).filter(([key]) => validKey.includes(key)).reduce((obj: Record, [key, value]) => { obj[key] = value; return obj; }, {}); + return ( + + ) +} + +export default StormWaterPump \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/page.tsx b/apps/sn/app/html/(map-layout)/map/page.tsx new file mode 100644 index 000000000..7f48a9243 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/page.tsx @@ -0,0 +1,184 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 + * + * 📄 Map Page + * 지도 페이지 컴포넌트 + * + * - OpenLayers 기반 GIS 지도 렌더링 및 상호작용 + * - 레이어 트리 데이터 관리 및 동적 레이어 표시/숨김 + * - 피처 검색 및 선택 기능 (SearchModal, ProcSearchBar) + * - 피처 클릭 이벤트 처리 및 상세 정보 팝업 표시 + * - 수심(WaterDepth) 레이어 애니메이션 재생 기능 + * - Zustand를 통한 검색 모달 상태 관리 + * + * 🔗 사용 API + * - 레이어 설정 조회: GET /api/v1/map/lyrStngs/{lyrGrp} (sewerage-service) + */ + +"use client"; + +import Gis from "@common/business_components/ui/map/Gis"; +import { getData } from "../../../components/lib/api"; +import { useCallback, useEffect, useRef, useState } from "react"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import SearchModal from "./searchModal/searchModal"; +import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; +import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; +import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; +import useSearchModalStore from "@/store/useSearchModalStore"; + +const Map = () => { + const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); + + const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + const gisMaps = useRef(null); + const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 + const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 + const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부 + const [isPlaying, setIsPlaying] = useState(false); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + try { + let data; + const res = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", { + method: "GET", + }); + if (res.ok) { + // 응답이 비어있는지 확인 + const contentLength = res.headers.get('content-length'); + if (contentLength === '0') data = null; + + // 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인 + const text = await res.text(); + if (!text || text.trim() === '') data = null; + + // JSON 파싱 시도 + data = JSON.parse(text); + } + setTreeData(data); + } catch (error) { + console.error('JSON parsing error:', error); + // JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환 + throw new Error('Invalid JSON response from server'); + } + }; + + // SearchModal, GisContent 등에서 호출 + const onFeatureSelect = (data: any) => { + let map = gisMaps.current; + if (!map) { + console.warn("지도가 초기화되지 않았습니다."); + return; + } + + // 항상 같은 select 객체 사용 + const select = GisApp.Module.select as any; + if (!select) return; + + // 1. 기존 선택 초기화 + select.resetSelectedFeature(); + + // 2. data가 OpenLayers Feature 인스턴스인지 확인 + let foundFeature = null; + if ( + data && + typeof data.getGeometry === "function" && + typeof data.setStyle === "function" + ) { + // 이미 Feature 인스턴스라면 그대로 사용 + foundFeature = data; + } else { + // 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용 + const featureId = data.fid || data.ftrIdn || data.id; + map + .getLayers() + .getArray() + .forEach((layer: any) => { + const source = layer.getSource?.(); + if (source?.getFeatureById) { + const feature = source.getFeatureById(String(featureId).trim()); + if (feature) foundFeature = feature; + } + }); + } + + // 3. 피처 선택 + if (foundFeature) { + let geometry = foundFeature.getGeometry(); + if (geometry) { + let extent = geometry.getExtent(); + + map.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + constrainResolution: true, + nearest: true, + }); + } + select.selectFeature(foundFeature, true); + } else { + console.warn("OpenLayers Feature를 찾을 수 없습니다:", data); + } + + setFeatureData(data); + setIsOpen(true); + setIsSearchModalOpen(false); + }; + + const facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }]; + // WaterDepth 표시 여부를 결정하는 함수 + const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { + const waterDepthLayers = checkedNodesData.filter(node => + WATER_DEPTH_LAYER_TYPES.includes(node.data?.lyrTyNm) + ); + + const hasWaterDepthLayer = waterDepthLayers.length > 0; + setShowWaterDepth(hasWaterDepthLayer); + }, []); + + // 체크된 레이어 변경 처리 함수 + const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => { + handleWaterDepthVisibility(checkedIds, checkedNodesData); + }, [handleWaterDepthVisibility]); + + + return ( + <> +
+ } + waterDepth={showWaterDepth ? WaterDepth : undefined} + isPlaying={isPlaying} + setIsPlaying={setIsPlaying as any} + targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} + checkInterval={2500} + showZoomLevel={true} + + defaultZoom={7.5} + defaultLonLat={[127.75, 36]} + /> +
+ + ); +}; + +export default Map; diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/basicTab.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/basicTab.tsx new file mode 100644 index 000000000..c21dc8ca4 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/basicTab.tsx @@ -0,0 +1,66 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 + * + * 📄 BasicTab Component + * Next.js App Router (app/) 기반 검색 모달의 기본정보 탭 컴포넌트 + * + * - 기본정보 검색을 위한 구분 선택 및 조건 입력 UI 제공 + * - 하수관로, 하수처리시설, 하수처리구역, 하수맨홀, 하수펌프장, 우수토실 등 다양한 유형별 컴포넌트 렌더링 + * - useSearchModalStore를 통해 상태 관리 및 사용자 상호작용 처리 + */ +import React from 'react'; +import { SelectBox } from '@common/business_components/ui'; +import { Label } from '@common/components'; +import SwagePpl from './swagePpl'; +import SwagePrcsFclt from './swagePrcsFclt'; +import SwagePrcsZone from './swagePrcsZone'; +import SwageMhl from './swageMhl'; +import SwagePmstn from './swagePmstn'; +import Stofcbr from './stofcbr'; +import useSearchModalStore from '@/store/useSearchModalStore'; + + +// 기본정보 '구분' 선택 목록 +export const BASIC_TYPES = [ + { val: "tb_ms_swage_ppl", text: "하수관로" }, + { val: "tb_il_swage_prcs_fclt", text: "하수처리시설" }, + { val: "tb_ms_swage_prcs_zone", text: "하수처리구역" }, + { val: "tb_ms_swage_mhl", text: "하수맨홀" }, + { val: "tb_ms_swage_pmstn", text: "하수펌프장" }, + { val: "tb_ms_stofcbr", text: "우수토실" }, +]; + +const BasicTab: React.FC = () => { + const { basicType, setBasicType } = useSearchModalStore(); + + return ( +
+ {/* 구분 */} +
+
+ + + setBasicType(String(value)) + } + value={basicType} + selectContentClass="z-[101]" + /> +
+
+ {/* 조회 조건 */} + {basicType === "tb_ms_swage_ppl" && ()} {/* 하수관로 */} + {basicType === "tb_il_swage_prcs_fclt" && ()} {/* 하수처리시설 */} + {basicType === "tb_ms_swage_prcs_zone" && ()} {/* 하수처리구역 */} + {basicType === "tb_ms_swage_mhl" && ()} {/* 하수맨홀 */} + {basicType === "tb_ms_swage_pmstn" && ()} {/* 하수펌프장 */} + {basicType === "tb_ms_stofcbr" && ()} {/* 우수토실 */} +
+ ); +}; + +export default React.memo(BasicTab); \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/operationTab.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/operationTab.tsx new file mode 100644 index 000000000..63c18875d --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/operationTab.tsx @@ -0,0 +1,98 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 유지관리 정보 탭 + * + * 📄 Operation Tab + * Next.js App Router (app/) 기반 검색 모달 유지관리 정보 탭 컴포넌트 + * + * - 업무구분 선택 (보수 및 갱신, 점검 및 진단, 청소 및 준설) + * - 사업명 입력 및 수행기간 범위 선택 + * - 사업 유형 필터링 기능 + * - 조회 버튼을 통한 데이터 검색 트리거 + * - Zustand를 통한 상태 관리 및 필터 값 저장 + * - Enter 키 입력 시 자동 조회 지원 + */ +import React from 'react'; +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { SelectBox } from '@common/business_components/ui'; +import { Button, Input, Label } from '@common/components'; +import useStore from '@/store/useStore'; +import useSearchModalStore from '@/store/useSearchModalStore'; +import useSearchModal from './useSearchModal'; +import useOperationTab from './useOperationTab'; + + +const OperationTab: React.FC = () => { + const { BIZ_TY_CD } = useStore(); + const { + workType, setWorkType, + var1, setVar1, var2, setVar2, + yearFilter, setYearFilter, + } = useSearchModalStore(); + const { handleSearch } = useSearchModal(); + const { WORK_TYPES } = useOperationTab(); + + const BIZ_TY_CD_SELECT_ARRAY = BIZ_TY_CD.map(item => ({ + val: item.lwpoCd, + text: item.lwpoCdNm, + })); + + return ( +
+
+ + ({ + val: w.val, + text: w.text, + }))} + onSelectValue={(val) => setWorkType(String(val))} + value={workType} + selectContentClass="z-[101]" + /> +
+
+ + { setVar1(e.target.value); }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + setYearFilter({ ...yearFilter, start: range.start, end: range.end })} + startLabel="수행기간" + endLabel="" + /> + + { + setVar2(String(val)); + }} + selectContentClass="z-[101]" + /> + {/* 조회 버튼 */} +
+ +
+
+
+ ) +}; + +export default React.memo(OperationTab); \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/reportFilesModal.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/reportFilesModal.tsx new file mode 100644 index 000000000..2b7218332 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/reportFilesModal.tsx @@ -0,0 +1,108 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭) + * + * 📄 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/html/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/reportTab.tsx new file mode 100644 index 000000000..ad57c0a5f --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/reportTab.tsx @@ -0,0 +1,272 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 + * + * 📄 Report Tab + * Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트 + * + * - 하수도정비 기본계획 보고서 목록 표시 및 필터링 + * - 시도/시군구/보고서 구분/승인년도 기반 검색 기능 + * - 테이블 데이터 표시 및 페이지네이션 지원 + * - 보고서 클릭 시 파일 목록 모달 열기 + * - Zustand를 통한 모달 상태 및 데이터 관리 + * - 로딩 오버레이 및 선택 초기화 기능 + */ +'use client'; +import React, { useEffect, useState } from 'react'; +import { Button, Label } from '@common/components'; +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { SelectBox, UiTable } from '@common/business_components/ui'; +import LoadingOverlay from '@/components/ui/LoadingBar'; +import useReportTab from './useReportTab'; +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'; // 파일목록모달 + + +// 하수도정비 기본계획 보고서 +export const reportTableHeadlist = [ + { id: "no", title: "NO" }, + { id: "sido", title: "시도" }, + { id: "sigungu", title: "시군구" }, + { id: "approvalYear", title: "승인년도" }, + { id: "plantZoneGubun", title: "구분" }, + { id: "reportGubun", title: "보고서구분" }, + { id: "fileName", title: "파일명" }, + { id: "regDate", title: "등록일자" }, +]; +interface ReportTabProps { + open?: boolean; + isSearch?: boolean; + ctpvNm?: string; + sggNm?: string; +} + +const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sggNm }) => { + + // const [isLoading, setIsLoading] = useState(false); + const [isResetPage, setIsResetPage] = useState(false); + const [isResetSelection, setIsResetSelection] = useState(false); + const [showTable, setShowTable] = useState(false); + // const [tableData, setTableData] = useState([]); + // const [tableCount, setTableCount] = useState(0); + // const [curPage, setCurPage] = useState(1); + // const [yearFilter, setYearFilter] = useState({ start: "", end: "" }); + const [ctpvCd, setCtpvCd] = useState('0'); + const [sggCd, setSggCd] = useState('0'); + const [reportGubun, setReportGubun] = useState("0"); + const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호 + + const { + tableData, + tableCount, + isLoading, + yearFilter, + curPage, + setTableData, + setTableCount, + setIsLoading, + setYearFilter, + setCurPage + } = useSearchModalStore(); + const { ctpv, sgg } = useStore(); + const { + isOpen: isReportFilesModalOpen, + setIsOpen: setIsReportFilesModalOpen, + dcTree: reportOriginData, + fetchDcTree + } = useReportTabStore(); // 모달 상태 관리 스토어 + const { REPORT_TYPES, flattenFolders, addFileName } = useReportTab(); + + useEffect(() => { + fetchDcTree("기본계획"); // 하수정비기본계획 report + }, []); + + useEffect(() => { + // filterParams 초기화 + setCtpvCd('0'); + setSggCd('0'); + setReportGubun('0'); + setYearFilter({ start: "", end: "" }); + setShowTable(false); + setTableData([]); + setTableCount(0); + setCurPage(1); // 페이지를 1로 초기화 + setIsResetPage(true); // 페이지를 초기화 + setIsResetSelection(true); // 선택 초기화 + }, [open]); + + useEffect(() => { + const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0'; + const newSggCd = sgg.find((n) => n.text === sggNm)?.val || '0'; + + setCtpvCd(newCtpvCd); + setSggCd(newSggCd); + }, [ctpvNm, sggNm]); + + // ctpvCd, sggCd가 업데이트된 후에 fetchFilteredData 실행 + useEffect(() => { + if (!isSearch && ctpvCd && sggCd) { + fetchFilteredData(); + } + }, [reportOriginData, ctpvCd, sggCd, isSearch]); + + // 페이지 변경 시마다 데이터 fetch + useEffect(() => { + // showTable이 true일 때만 fetch (최초 렌더 방지) + setIsResetPage(false); + + if (showTable) fetchFilteredData(); + }, [curPage]); + + const handleCtpvCdChange = (sidoId: string) => { + setCtpvCd(sidoId); + setSggCd('0'); + } + + // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 + const handleSearch = () => { + setCurPage(1); // 페이지를 1로 초기화 + fetchFilteredData(); + setIsResetPage(true); // 페이지를 초기화 + setIsResetSelection(false); // 선택 초기화 + setShowTable(true); + }; + + // 하수도정비기본계획 테이블 cellClick 핸들러 + const handleReportCellClick = (index: number | undefined) => { + if (typeof index === "number" && tableData[index]) { + const reportData = tableData[index]; + if (reportData.fileSn !== "") { + setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정 + setIsReportFilesModalOpen(true); // 모달 열기 + } + } + }; + + // 검색 API 호출 함수 + const fetchFilteredData = async () => { + setIsLoading(true); // 로딩 시작 + setIsResetSelection(true); // 선택 초기화 + + try { + const reportData = flattenFolders(reportOriginData); + let filterReportData = reportData; + + if (ctpvCd !== '0') { // 시도검색 + const sidoOption = ctpv.find((n) => n.val.trim() === ctpvCd.trim()); + filterReportData = filterReportData.filter(item => item.sido === sidoOption?.text); + } + if (sggCd !== '0') { // 시군구검색 + const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd)); + filterReportData = filterReportData.filter(item => item.sigungu === sigunguOption?.text); + } + if (reportGubun !== "0") { // 보고서구분검색 + const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun); + filterReportData = filterReportData.filter(item => item.reportGubun === reportGubunOption?.text); + } + + const { start, end } = yearFilter; + if (start && !end) //승인년도검색 + filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" : null); + else if (!start && end) + filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear <= yearFilter.end + "년" : null); + else if (start && end) { + filterReportData = filterReportData.filter(item => { + return (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" && item.approvalYear <= yearFilter.end + "년" : null; + }); + } + // if (gubun !== "") { // 구분검색 (추후 사용할 수도 있을 것 같아 남겨둠) + // filterReportData = filterReportData.filter(item => (item.plantZoneGubun) ? String(item.plantZoneGubun).includes(String(gubun)) : null); + // } + + setTableCount(filterReportData.length || 0); + + filterReportData = filterReportData.slice((curPage - 1) * 10, curPage * 10); // paging + filterReportData = filterReportData?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 })); + + addFileName(filterReportData).then(data => { + filterReportData = data; + setTableData(filterReportData || []); + setIsResetSelection(false); // 데이터 설정 후 선택 초기화 해제 + }); + + setTableData(filterReportData || []); + setShowTable(true); + } catch (err) { + console.error('API ERROR:', err); + setTableData([]); + setIsResetSelection(false); // 에러 시에도 선택 초기화 해제 + } finally { + setIsLoading(false); // 로딩 종료 + } + }; + + + return ( + <> +
+ {isSearch && ( + <> +
+ { handleCtpvCdChange(sidoId) }} + onSigunguChange={setSggCd} + setIsLoading={setIsLoading} + /> +
+ + )} +
+ + setReportGubun(String(val))} + selectContentClass="z-[101]" labelClass='whitespace-nowrap text-xl text-gray-600 pr-2' + /> + {/* 승인년도 필터 */} + setYearFilter({ ...yearFilter, start: range.start, end: range.end })} + startLabel="승인년도" endLabel="" + /> + {/* 조회 버튼 */} +
+ +
+
+
+ +
+ {/* 테이블2 영역 (가로/세로 스크롤 가능) */} +
+ + { + return (<>조회 결과); + })() + } + tableData={tableData} + headlist={reportTableHeadlist} + pageSize={10} + total={tableCount} + cellClick={handleReportCellClick} + onPageChange={setCurPage} + resetPageNo={isResetPage} + resetSelection={isResetSelection} + /> +
+
+ {/* 모달 컴포넌트 */} + {isReportFilesModalOpen && } + + ); +}; + +export default React.memo(ReportTab); \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/searchModal.tsx new file mode 100644 index 000000000..3ce334518 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/searchModal.tsx @@ -0,0 +1,238 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 + * + * 📄 Search Modal + * Next.js App Router (app/) 기반 검색 모달 컴포넌트 + * + * - 탭 기반 검색 인터페이스 (기본정보, 유지관리 정보, 보고서) + * - 테이블 데이터 표시 및 페이지네이션 기능 + * - 시설물 조회 결과 및 유지관리 사업 현황 표시 + * - 엑셀 다운로드 및 지도에서 보기 기능 + * - Zustand를 통한 검색 모달 상태 관리 + * - 로딩 오버레이 및 선택 초기화 지원 + */ +import LoadingOverlay from '@/components/ui/LoadingBar'; +import { UiModal, UITab, UiTable } from '@common/business_components/ui'; +import { Button } from '@common/components/ui'; +import React, { useEffect, useMemo } 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'; + +const SearchModal: React.FC = () => { + const { + showTable, + isSearchModalOpen, setIsSearchModalOpen, + activeTab, basicType, workType, + tableData, detailTableData, + tableCount, detailTableCount, + curPage, detailCurPage, + detailBizId, + isLoading, isDetailLoading, + isResetPage, isResetDetailPage, + isResetSelection, isResetDetailSelection, + setActiveTab, + setCurPage, setDetailCurPage, + setIsResetPage, setIsResetDetailPage, + setDetailBizId, + setIsResetDetailSelection, + resetFilters + } = useSearchModalStore(); + const { TABS, viewMultipleFeature, fetchFilteredData } = useSearchModal(); + const { handleBasicCellClick, basicExcelDownload } = useBasicTab(); + const { + WORK_TYPES, + OPERATION_TABLE_SIZE, + OPERATION_TABLE_HEADLIST, + MAINTENANACE_TABLE_HEADLIST, + CLEANING_TABLE_HEADLIST, + INSPECTION_TABLE_HEADLIST, + handleOperationCellClick, + handleOperationResultCellClick, + operationExcelDownload, + operationResultExcelDownload, + handleOperationPageNation + } = useOperationTab(); + + + const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => { + if (activeTab === 'basic') { // 기본정보 + if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;// 하수관로 + if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;// 하수처리시설 + if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2; // 하수처리구역 + if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2; // 하수맨홀 + if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2; // 하수펌프장 + if (basicType === 'tb_ms_stofcbr') return stofcbrCol2; // 우수토실 + + return []; + } + if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; // 유지관리 정보 + + return []; + }, [activeTab, basicType, workType]); + + const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => { + if (activeTab === 'operation') { + if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST; // 보수 및 갱신 + if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST; // 점검 및 진단 + if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST; // 청소 및 준설 + } + + return []; + }, [activeTab, workType]); + + useEffect(() => { + resetFilters(); + }, [activeTab, basicType, workType]); + + // 페이지 변경 시마다 데이터 fetch + useEffect(() => { + setIsResetPage(false); + setIsResetDetailPage(true); + setDetailBizId(0); + if (showTable) fetchFilteredData(); + }, [curPage]); + + // 페이지 변경 시마다 데이터 fetch + useEffect(() => { + setIsResetDetailSelection(false); + if (showTable) handleOperationPageNation(Number(detailBizId)); + }, [detailCurPage]); + + + const renderBody = () => ( + <> + {/* 탭 영역 */} + ({ + value: tab.val, + label: tab.text + }))} + onValueChange={(value: string) => setActiveTab(value)} + /> + {activeTab === 'basic' && ()} {/* 기본정보 탭 */} + {activeTab === 'operation' && ()} {/* 유지관리 정보 탭 */} + {activeTab === 'report' && ()} {/* 하수도정비기본계획보고서 탭 */} + + + {/* 📋 결과 리스트 */} + {/* 기본정보 탭 */} + {activeTab === 'basic' && ( +
+ + { + const label = BASIC_TYPES.find((type) => type.val === basicType)?.text + return (<>{label} 조회 결과); + })() + } + tableData={tableData} + headlist={dynamicHeadlist} + pageSize={10} + total={tableCount} + cellClick={handleBasicCellClick} + onPageChange={setCurPage} + resetPageNo={isResetPage} + resetSelection={isResetSelection} + curPage={curPage} + headButton={ +
+ + +
+ } + /> +
+ )} + {/* 유지관리 정보 탭 */} + {activeTab === 'operation' && ( + <> +
+ {/*
*/} + + { + const label = WORK_TYPES.find((type) => type.val === workType)?.text + return (<>{label} 사업 현황); + })() + } + tableData={tableData} + headlist={dynamicHeadlist} + pageSize={OPERATION_TABLE_SIZE} + total={tableCount} + cellClick={handleOperationCellClick} + onPageChange={setCurPage} + resetPageNo={isResetPage} + resetSelection={isResetSelection} + curPage={curPage} + headButton={ + + } + /> + {/*
*/} +
+ +
+ {/* 테이블2 영역 */} + {/*
*/} + + { + const label = WORK_TYPES.find((type) => type.val === workType)?.text + return (<>{label} 결과표); + })() + } + tableData={detailTableData} + headlist={dynamicHeadlist2} + pageSize={OPERATION_TABLE_SIZE} + total={detailTableCount} + cellClick={handleOperationResultCellClick} + onPageChange={setDetailCurPage} + resetPageNo={isResetDetailPage} + resetSelection={isResetDetailSelection} + curPage={detailCurPage} + headButton={ +
+ + +
+ } + /> + {/*
*/} +
+ + )} + + ); + + return ( +
+ + +
+ ); +}; + +export default React.memo(SearchModal); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/stofcbr.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/stofcbr.tsx new file mode 100644 index 000000000..102b21d64 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/stofcbr.tsx @@ -0,0 +1,92 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 우수토실) + * + * 📄 Stofcbr Tab + * Next.js App Router (app/) 기반 검색 모달 우수토실 탭 컴포넌트 + * + * - 시도/시군구 선택 및 설치년도 범위 필터링 + * - CQL 필터 생성을 통한 쿼리 조건 설정 + * - 조회 버튼을 통한 데이터 검색 트리거 + * - Zustand를 통한 상태 관리 및 필터 값 저장 + * - Enter 키 입력 시 자동 조회 지원 + */ +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { Button } from '@common/components'; +import React from 'react'; +import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; +import useSearchModalStore from "@/store/useSearchModalStore"; +import useSearchModal from './useSearchModal'; + + +export const stofcbrCqlFilters = () => { + const { + ctpvCd, sggCd, + yearFilter, + todayYear, todayMonth, todayDate, + getDateString + } = useSearchModalStore.getState(); + + const cqlFilters: string[] = []; + // 시도, 시군구 + if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`); + else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`); + // 설치년도 범위 + const startDate = getDateString( + yearFilter.start, + '', + '', + "19000101", + ); + const endDate = getDateString( + yearFilter.end, + '', + '', + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`, + ); + + return cqlFilters; +}; + +const Stofcbr: React.FC = () => { + const { + ctpvCd, setCtpvCd, + sggCd, setSggCd, + yearFilter, setYearFilter, + } = useSearchModalStore(); + const { handleSearch } = useSearchModal(); + + + return ( +
+
+ {/* 시도, 시군구 */} + { + setCtpvCd(sidoId); + setSggCd("0"); + }} + onSigunguChange={setSggCd} + /> + {/* 설치년도 */} + setYearFilter({ ...yearFilter, start: range.start, end: range.end })} + startLabel="설치년도" + endLabel="" + /> + {/* 조회 버튼 */} +
+ +
+
+
+ ); +}; + +export default React.memo(Stofcbr); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swageMhl.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swageMhl.tsx new file mode 100644 index 000000000..266012dff --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/swageMhl.tsx @@ -0,0 +1,156 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수맨홀) + * + * 📄 SwageMhl Tab + * Next.js App Router (app/) 기반 검색 모달 하수맨홀 탭 컴포넌트 + * + * - 시도/시군구 선택 및 설치년도 범위 필터링 + * - 용도 및 구경 범위 기반 검색 기능 + * - CQL 필터 생성을 통한 쿼리 조건 설정 + * - 조회 버튼을 통한 데이터 검색 트리거 + * - Zustand를 통한 상태 관리 및 필터 값 저장 + * - Enter 키 입력 시 자동 조회 지원 + */ +import React from 'react'; +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { Button, Input, Label } from '@common/components'; +import { MS_SWAGE_MHL_USE_CODES } from '../components/common/table'; +import { SelectBox } from '@common/business_components/ui'; +import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; +import useSearchModalStore from "@/store/useSearchModalStore"; +import useSearchModal from './useSearchModal'; + + +export const swageMhlCqlFilters = () => { + const { + ctpvCd, sggCd, + yearFilter, + start1, end1, + var1, + todayYear, todayMonth, todayDate, + getDateString + } = useSearchModalStore.getState(); + + const cqlFilters: string[] = []; + // 시도, 시군구 + if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`); + else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`); + // // 설치년도 범위 + const startDate = getDateString( + yearFilter.start, + '', + '', + "19000101", + ); + const endDate = getDateString( + yearFilter.end, + '', + '', + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`, + ); + if (var1) cqlFilters.push(`usg_cd='${var1}'`); + // 구경(mm) 범위 + if (start1 && end1) cqlFilters.push(`swage_mhL_diam_vl BETWEEN ${start1} AND ${end1}`); + else if (start1) cqlFilters.push(`swage_mhL_diam_vl >= ${start1}`); + else if (end1) cqlFilters.push(`swage_mhL_diam_vl <= ${end1}`); + + return cqlFilters; +}; + +const SwageMhl: React.FC = () => { + const { + var1, setVar1, + start1, setStart1, end1, setEnd1, + ctpvCd, setCtpvCd, + sggCd, setSggCd, + yearFilter, setYearFilter, + } = useSearchModalStore(); + const { handleSearch } = useSearchModal(); + + return ( +
+
+ { + setCtpvCd(sidoId); + setSggCd("0"); + }} + onSigunguChange={setSggCd} + /> + {/* 설치년도 */} + + setYearFilter({ + ...yearFilter, + start: range.start, + end: range.end, + }) + } + startLabel="설치년도" + endLabel="" + /> + {/* 용도 */} + + { + setVar1(String(val)); + }} + selectContentClass="z-[101]" + /> + {/* 하수맨홀 구경 */} + + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setStart1(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + ~ + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setEnd1(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + {/* 조회 버튼 */} +
+ +
+
+
+ ); +}; + +export default React.memo(SwageMhl); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swagePmstn.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swagePmstn.tsx new file mode 100644 index 000000000..3512b4fa3 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/swagePmstn.tsx @@ -0,0 +1,175 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수펌프장) + * + * 📄 SwagePmstn Tab + * Next.js App Router (app/) 기반 검색 모달 하수펌프장 탭 컴포넌트 + * + * - 시도/시군구 선택 및 설치년도 범위 필터링 + * - 일일처리용량 및 양수능력 범위 기반 검색 기능 + * - CQL 필터 생성을 통한 쿼리 조건 설정 + * - 조회 버튼을 통한 데이터 검색 트리거 + * - Zustand를 통한 상태 관리 및 필터 값 저장 + * - Enter 키 입력 시 자동 조회 지원 + */ +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { Button, Input, Label } from '@common/components'; +import React from 'react'; +import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; +import useSearchModalStore from "@/store/useSearchModalStore"; +import useSearchModal from './useSearchModal'; + + +export const swagePmstnCqlFilters = () => { + const { + ctpvCd, sggCd, + yearFilter, + start1, start2, end1, end2, + todayYear, todayMonth, todayDate, + getDateString + } = useSearchModalStore.getState(); + + const cqlFilters: string[] = []; + // 시도, 시군구 + if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`); + else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`); + // 설치년도 범위 + const startDate = getDateString( + yearFilter.start, + '', + '', + "19000101", + ); + const endDate = getDateString( + yearFilter.end, + '', + '', + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`, + ); + // 일일처리용량 범위 + if (start1 && end1) + cqlFilters.push(`daily_prcs_cpct BETWEEN ${start1} AND ${end1}`); + else if (start1) cqlFilters.push(`daily_prcs_cpct >= ${start1}`); + else if (end1) cqlFilters.push(`daily_prcs_cpct <= ${end1}`); + // 양수능력 범위 + if (start2 && end2) + cqlFilters.push(`excln_wpmp_ablt_vl BETWEEN ${start2} AND ${end2}`); + else if (start2) cqlFilters.push(`excln_wpmp_ablt_vl >= ${start2}`); + else if (end2) cqlFilters.push(`excln_wpmp_ablt_vl <= ${end2}`); + + return cqlFilters; +}; + +const SwagePmstn: React.FC = () => { + const { + start1, setStart1, + end1, setEnd1, + ctpvCd, setCtpvCd, + sggCd, setSggCd, + yearFilter, setYearFilter, + } = useSearchModalStore(); + const { handleSearch } = useSearchModal(); + + return ( +
+
+ {/* 시도, 시군구 */} + { + setCtpvCd(sidoId); + setSggCd("0"); + }} + onSigunguChange={setSggCd} + /> + {/* 설치년도 */} + setYearFilter({ ...yearFilter, start: range.start, end: range.end })} + startLabel="설치년도" + endLabel="" + /> + {/* 일일처리용량 */} + + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setStart1(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + ~ + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setEnd1(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + {/* 양수능력 */} + {/* + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setStart2(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + ~ + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setEnd2(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> */} + {/* 조회 버튼 */} +
+ +
+
+
+ ); +}; + +export default React.memo(SwagePmstn); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swagePpl.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swagePpl.tsx new file mode 100644 index 000000000..5bc8f900b --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/swagePpl.tsx @@ -0,0 +1,209 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수관로) + * + * 📄 SwagePpl Tab + * Next.js App Router (app/) 기반 검색 모달 하수관로 탭 컴포넌트 + * + * - 시도/시군구 선택 및 설치년도 범위 필터링 + * - 용도 및 관경 범위 기반 검색 기능 + * - CQL 필터 생성을 통한 쿼리 조건 설정 + * - 조회 버튼을 통한 데이터 검색 트리거 + * - Zustand를 통한 상태 관리 및 필터 값 저장 + * - Enter 키 입력 시 자동 조회 지원 + */ +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { SelectBox } from '@common/business_components/ui'; +import { Button, Input, Label } from '@common/components'; +import React from 'react'; +import { MS_SWAGE_PPL_USE_CODES } from '../components/common/table'; +import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; +import useSearchModalStore from "@/store/useSearchModalStore"; +import useSearchModal from './useSearchModal'; + + +// 하수관로용 CQL 필터 생성 +export const swagePplCqlFilters = () => { + const { + var1, var2, + ctpvCd, sggCd, + yearFilter, + start1, start2, end1, end2, + todayYear, todayMonth, todayDate, + getDateString + } = useSearchModalStore.getState(); + + const cqlFilters: string[] = []; + if (var1) cqlFilters.push(`usg_cd='${var1}'`); + if (var2) cqlFilters.push(`mqlt_cd='${var2}'`); + if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`); + else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`); + // 설치년도 범위 + const startDate = getDateString( + yearFilter.start, + '', + '', + "19000101", + ); + const endDate = getDateString( + yearFilter.end, + '', + '', + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`, + ); + // 관경(mm) 범위 + if (start1 && end1) cqlFilters.push(`pdmt_vl BETWEEN ${start1} AND ${end1}`); + else if (start1) cqlFilters.push(`pdmt_vl >= ${start1}`); + else if (end1) cqlFilters.push(`pdmt_vl <= ${end1}`); + // 연장(m) 범위 + if (start2 && end2) cqlFilters.push(`prlg_vl BETWEEN ${start2} AND ${end2}`); + else if (start2) cqlFilters.push(`prlg_vl >= ${start2}`); + else if (end2) cqlFilters.push(`prlg_vl <= ${end2}`); + + return cqlFilters; +}; + +const SwagePpl: React.FC = () => { + const { + var1, setVar1, + start1, setStart1, end1, setEnd1, + ctpvCd, setCtpvCd, sggCd, setSggCd, + yearFilter, setYearFilter, + } = useSearchModalStore(); + const { handleSearch } = useSearchModal(); + + + return ( +
+
+ { + setCtpvCd(sidoId); + setSggCd("0"); + }} + onSigunguChange={setSggCd} + /> + {/* 설치년도 */} + + setYearFilter({ + ...yearFilter, + start: range.start, + end: range.end, + }) + } + startLabel="설치년도" + endLabel="" + /> + {/* 용도 */} + + { + setVar1(String(val)); + }} + selectContentClass="z-[101]" + /> + {/* 재질 */} + {/* + { + setVar2(String(val)); + }} + selectContentClass="z-[101]" + /> */} + {/* 관경 */} + + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setStart1(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + ~ + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setEnd1(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + {/* 연장 */} + {/* + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setStart2(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + ~ + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setEnd2(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> */} + {/* 조회 버튼 */} +
+ +
+
+
+ ); +}; + +export default React.memo(SwagePpl); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsFclt.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsFclt.tsx new file mode 100644 index 000000000..c541a4c2f --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsFclt.tsx @@ -0,0 +1,150 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리시설) + * + * 📄 SwagePrcsFclt Tab + * Next.js App Router (app/) 기반 검색 모달 하수처리시설 탭 컴포넌트 + * + * - 시도/시군구 선택 및 가동개시일자 범위 필터링 + * - 시설용량 범위 기반 검색 기능 + * - CQL 필터 생성을 통한 쿼리 조건 설정 + * - 조회 버튼을 통한 데이터 검색 트리거 + * - Zustand를 통한 상태 관리 및 필터 값 저장 + * - Enter 키 입력 시 자동 조회 지원 + */ +import React from 'react'; +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { Button, Input, Label } from '@common/components'; +import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; +import useSearchModalStore from "@/store/useSearchModalStore"; +import useSearchModal from './useSearchModal'; + + +// 하수처리시설용 CQL 필터 생성 +export const swagePrcsFcltCqlFilters = () => { + const { + ctpvName, sggName, + yearFilter, + start1, end1, + todayYear, todayMonth, todayDate, + getDateString + } = useSearchModalStore.getState(); + + const cqlFilters: string[] = []; + // 가동개시일자 범위 + const startDate = getDateString( + yearFilter.start, + '', + '', + "19000101", + ); + const endDate = getDateString( + yearFilter.end, + '', + '', + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `oprtng_strt_ymd >= '${startDate}' and oprtng_strt_ymd <= '${endDate}'`, + ); + // 시설용량 범위 + if (start1 && end1) + cqlFilters.push(`fclt_cpct BETWEEN ${start1} AND ${end1}`); + else if (start1) cqlFilters.push(`fclt_cpct >= ${start1}`); + else if (end1) cqlFilters.push(`fclt_cpct <= ${end1}`); + + // 시도 + if (ctpvName) cqlFilters.push(`ctpv_nm ILIKE '%${ctpvName}%'`); + if (sggName) cqlFilters.push(`sgg_nm ILIKE '%${sggName}%'`); + // // 처리구역 면적 + // if (start2 && end2) + // cqlFilters.push(`prcsZoneAreaSum BETWEEN ${start2} AND ${end2}`); + // else if (start2) cqlFilters.push(`prcsZoneAreaSum >= ${start2}`); + // else if (end2) cqlFilters.push(`prcsZoneAreaSum <= ${end2}`); + // // 하수처리시설명 + // if (var1) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${var1}%'`); + return cqlFilters; +}; + +const SwagePrcsFclt: React.FC = () => { + const { + start1, setStart1, end1, setEnd1, + ctpvCd, setCtpvCd, setCtpvName, + sggCd, setSggCd, setSggName, + yearFilter, setYearFilter, + } = useSearchModalStore(); + const { handleSearch } = useSearchModal(); + + return ( +
+
+ { + setCtpvCd(sidoId); + setSggCd("0"); + }} + onSigunguChange={setSggCd} + setCtpvName={setCtpvName} + setSggName={setSggName} + /> + {/* 가동개시일자 */} + + + setYearFilter({ + ...yearFilter, + start: range.start, + end: range.end, + }) + } + startLabel="" + endLabel="" + /> + {/* 시설용량 */} + + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setStart1(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + ~ + { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setEnd1(val); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + {/* 조회 버튼 */} +
+ +
+
+
+ ); +}; + +export default React.memo(SwagePrcsFclt); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsZone.tsx b/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsZone.tsx new file mode 100644 index 000000000..e433662d1 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/swagePrcsZone.tsx @@ -0,0 +1,93 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리구역) + * + * 📄 SwagePrcsZone Tab + * Next.js App Router (app/) 기반 검색 모달 하수처리구역 탭 컴포넌트 + * + * - 시도/시군구 선택 및 하수처리시설명/구역명 기반 검색 기능 + * - CQL 필터 생성을 통한 쿼리 조건 설정 + * - 조회 버튼을 통한 데이터 검색 트리거 + * - Zustand를 통한 상태 관리 및 필터 값 저장 + * - Enter 키 입력 시 자동 조회 지원 + */ +import React from 'react'; +import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; +import { Button, Input, Label } from '@common/components'; +import useSearchModalStore from "@/store/useSearchModalStore"; +import useSearchModal from './useSearchModal'; + + +// 하수처리구역용 CQL 필터 생성 +export const swagePrcsZoneCqlFilters = () => { + const { + ctpvCd, sggCd, + var1, var2 + } = useSearchModalStore.getState(); + const cqlFilters: string[] = []; + if (ctpvCd !== '' && ctpvCd !== '0') cqlFilters.push(`ctpv_cd='${ctpvCd?.trim()}'`); + if (sggCd !== '' && sggCd !== '0') cqlFilters.push(`sgg_cd='${sggCd}'`); + if (var1) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${var1}%'`); + if (var2) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${var2}%'`); + return cqlFilters; +}; + +const swagePrcsZone: React.FC = () => { + const { + var1, setVar1, var2, setVar2, + ctpvCd, setCtpvCd, + sggCd, setSggCd, + } = useSearchModalStore(); + const { handleSearch } = useSearchModal(); + + return ( +
+
+ { + setCtpvCd(sidoId); + setSggCd("0"); + }} + onSigunguChange={setSggCd} + /> + + { + setVar2(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + + { + setVar1(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Enter 키 기본 동작 방지 + handleSearch(); + } + }} + /> + {/* 조회 버튼 */} +
+ +
+
+
+ ); +}; + +export default React.memo(swagePrcsZone); diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/html/(map-layout)/map/searchModal/useBasicTab.ts new file mode 100644 index 000000000..9184286ed --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/useBasicTab.ts @@ -0,0 +1,348 @@ +/** + * 📄 useBasicTab Hook + * 검색 모달 기본정보 탭 훅 + * + * - 필터링된 시설물 데이터 조회 및 포맷팅 + * - 테이블 셀 클릭 시 지도 피처 선택 및 줌 기능 + * - 엑셀 다운로드 기능 (한글 헤더 지원) + * - CQL 필터 생성을 통한 쿼리 조건 설정 + * - Zustand를 통한 상태 관리 및 로딩 처리 + * - 데이터 매핑 및 코드 변환 (시도/시군구, 용도 등) + * + * 🔗 사용 API + * - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service) + */ +import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui'; +import { + MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, + MsSwagePplExcelCol, SwtrmtFcltExcelCol, SwtrmtPrcsZoneExcelCol, MsSwageMhlExcelCol, ExclnSewagePumpExcelCol, stofcbrExcelCol +} from '../components/common/table'; +import { getData } from "@/components/lib/api"; +import { BASIC_TYPES } from './basicTab'; +import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; +import useStore from '@/store/useStore'; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { confirmToast } from '@/components/ui/toast' +import { toast } from "react-hot-toast"; +import useSearchModalStore from '@/store/useSearchModalStore'; +import { swagePplCqlFilters } from './swagePpl'; +import { swagePrcsFcltCqlFilters } from './swagePrcsFclt'; +import { swagePrcsZoneCqlFilters } from './swagePrcsZone'; +import { swageMhlCqlFilters } from './swageMhl'; +import { swagePmstnCqlFilters } from './swagePmstn'; +import { stofcbrCqlFilters } from './stofcbr'; + + +export default function useBasicTab() { + const { + basicType, + tableData, + curPage, + todayYear, todayMonth, todayDate, + setIsSearchModalOpen, + setTableData, + setTableCount, + setIsLoading, + } = useSearchModalStore(); + const { ctpv, sgg } = useStore(); + + + const fetchBasicFilteredData = async () => { + let cqlFilters: string[] = []; + const queryParams = new URLSearchParams({ + lyrTypeNm: basicType, + page: `${curPage - 1}`, + size: '10', + sort: 'string', + }); + + if (basicType === 'tb_ms_swage_ppl') cqlFilters = swagePplCqlFilters(); // 하수관로 + else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설 + else if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = swagePrcsZoneCqlFilters(); // 하수처리구역 + else if (basicType === 'tb_ms_swage_mhl') cqlFilters = swageMhlCqlFilters(); // 하수맨홀 + else if (basicType === 'tb_ms_swage_pmstn') cqlFilters = swagePmstnCqlFilters(); // 하수펌프장 + else if (basicType === 'tb_ms_stofcbr') cqlFilters = stofcbrCqlFilters(); // 우수토실 + if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); + + const res = await getData(`v1/fclts?${queryParams.toString()}`); + + // 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가 + const dataWithRowNumber = (res.data || []).map((item: any, index: number) => { + // 🎯 먼저 각 아이템을 포맷팅 + const formattedItem = formatDisplayData(item); + + let ctpvCd = item?.ctpvNm ?? formattedItem?.ctpvNm ?? ''; + let sggCd = item?.sggNm ?? formattedItem?.sggNm ?? ''; + + if (!ctpvCd || !sggCd) { + const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? '')?.trim(); + if (!ctpvCd && code) ctpvCd = code.slice(0, 2); + if (!sggCd && code) sggCd = code.slice(0, 5); + } + + const matchedMqltCode = basicType === 'tb_ms_swage_ppl' + ? MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 하수관로 재질 + : basicType === 'tb_ms_swage_mhl' + ? MS_SWAGE_MHL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 맨홀 재질 + : undefined; + const matchedMhlUsgCode = MS_SWAGE_MHL_USE_CODES.find(code => code.val === item?.usgCd); // 맨홀 용도 + const matchedPplShpCode = MS_SWAGE_PPL_SHAPE.find(code => code.val === item?.fcltyShpCd); // 하수관로 시설물형태코드 + + + const ctpvNm = ctpv.find(code => code.val.trim() === ctpvCd)?.text || item?.ctpvNm; + const sggNm = sgg.find(code => code.val.trim() === sggCd)?.text || item?.sggNm; + const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim()); + + // 추가 처리 및 일련번호 추가 + return { + ...formattedItem, + rowNumber: (curPage - 1) * 10 + index + 1, // 1부터 시작하는 일련번호 추가 + scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item?.scsYn, // scsYn이 null이면 'N'으로 변경 + mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd, + usgCd: matchedMhlUsgCode ? matchedMhlUsgCode.text : (usgCdObj ? usgCdObj.text : item?.usgCd), + ctpvNm: ctpvNm ?? '-', + sggNm: sggNm ?? '-', + swtrmt_zone_nm: item.swtrmtZoneNm || item.swtrmt_zone_nm || '-', + fcltyShpCd: matchedPplShpCode? matchedPplShpCode.text : item?.fcltyShpCd, + }; + }); + + setTableData(dataWithRowNumber); + // console.log(tableData); + setTableCount(res.count || []); + }; + + // 기본정보 테이블 cellClick 핸들러 + const handleBasicCellClick = async (index?: number | undefined, headId?: String) => { + if (typeof index === "number") { + setIsLoading(true); + + const row = tableData[index]; + const layerNm = row.lyrTypeNm; + const fid = row.fid; + const core = GisApp.Module.layer as any; + await core.showSearchResultsFromGeoJSON(layerNm, fid); + setIsLoading(false); + setIsSearchModalOpen(false); // SearchModal 닫기 + } + }; + + // 기본정보 Excel 다운로드 핸들러 + const basicExcelDownload = async () => { + if (tableData.length === 0) { + alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요."); + return; + } + + toast.dismiss("global-confirm-toast"); + + confirmToast( + "파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?", + onDownload ?? (() => { }), + "text-xl font-medium text-gray-800" + ); + + async function onDownload() { + setIsLoading(true); + + try { + const queryParams = new URLSearchParams({ + lyrTypeNm: basicType, + size: '10000', + }); + + let cqlFilters: string[] = []; + let headerMapping: { [key: string]: string } = {}; + let headerRange: string = "A1:F1"; + + switch (basicType) { + case 'tb_ms_swage_ppl': { + // 하수관로 + cqlFilters = swagePplCqlFilters() + headerMapping = MsSwagePplExcelCol; + headerRange = "A1:Q1"; + break; + } + case 'tb_il_swage_prcs_fclt': { + // 하수처리시설 + cqlFilters = swagePrcsFcltCqlFilters(); + headerMapping = SwtrmtFcltExcelCol; + headerRange = "A1:J1"; + break; + } + case 'tb_ms_swage_prcs_zone': { + // 하수처리구역 + cqlFilters = swagePrcsZoneCqlFilters(); + headerMapping = SwtrmtPrcsZoneExcelCol; + headerRange = "A1:D1"; + break; + } + case 'tb_ms_swage_mhl': { + // 하수맨홀 + cqlFilters = swageMhlCqlFilters(); + headerMapping = MsSwageMhlExcelCol; + headerRange = "A1:J1"; + break; + } + case 'tb_ms_swage_pmstn': { + // 하수펌프장 + cqlFilters = swagePmstnCqlFilters(); + headerMapping = ExclnSewagePumpExcelCol; + headerRange = "A1:J1"; + break; + } + case 'tb_ms_stofcbr': { + // 우수토실 + cqlFilters = stofcbrCqlFilters(); + headerMapping = stofcbrExcelCol; + headerRange = "A1:H1"; + break; + } + } + + if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); + + const res = await getData(`v1/fclts?${queryParams.toString()}`); + + const mhlUsgCdMap = new Map(MS_SWAGE_MHL_USE_CODES.map(code => [code.val?.trim(), code.text])); + const pplUsgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val?.trim(), code.text])); + const pplMqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val?.trim(), code.text])); + const pplShpCdMap = new Map(MS_SWAGE_PPL_SHAPE.map(code => [code.val?.trim(), code.text])); // 관로 시설물형태코드 + const ctpvMap = new Map(ctpv.map(code => [code.val?.trim(), code.text])); + const sggMap = new Map(sgg.map(code => [code.val?.trim(), code.text])); + + const mappedContent = (res.data || []).map((item: any) => { + const formattedItem = formatDisplayData(item); + + let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? ''; + let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? ''; + + if (!ctpvCd || !sggCd) { + const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? '')?.trim(); + if (!ctpvCd && code) ctpvCd = code.slice(0, 2); + if (!sggCd && code) sggCd = code.slice(0, 5); + } + + // trim 한 번만 수행 + const trimmedUsgCd = item?.usgCd?.trim(); + // const trimmedMqltCd = item?.mqltCd?.trim(); + + switch (basicType) { + case 'tb_ms_swage_ppl': { + // 하수관로 + return { + fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-', + instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? '-', + ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? '-', + sggNm: sggMap.get(sggCd) ?? item.sggNm ?? '-', + swtrmt_zone_nm: formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? '-', + instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-', + usgCd: pplUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-', + pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '-', + prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '-', + tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? '-', + tmptMhlMngNo: formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? '-', + scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? '-', + scendMhlMngNo: formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? '-', + vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-', + wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-', + fcltyShpCd: pplShpCdMap.get(item?.fcltyShpCd?.trim()) || item.fcltyShpCd || formattedItem.fcltyShpCd || '-', + mqltCd: pplMqltCdMap.get(item?.mqltCd?.trim()) || item.mqltCd || formattedItem.mqltCd || '-', + }; + } + case 'tb_il_swage_prcs_fclt': { + // 하수처리시설 + return { + swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-', + fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? '-', + rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? '-', + oprtngStrtYmd: formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? '-', + laeNm: formattedItem.laeNm ?? item.laeNm ?? '-', + sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? '-', + rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? '-', + dschgWtstNm: formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? '-', + swtrmtPpltnCnt: formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? '-', + rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? '-' + }; + } + case 'tb_ms_swage_prcs_zone': { + // 하수처리구역 + return { + swtrmtZoneNm: formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? '-', + ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-', + sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-', + swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-' + }; + } + case 'tb_ms_swage_mhl': { + // 하수맨홀 + return { + fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-', + ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-', + sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-', + instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-', + usgCd: mhlUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-', + swageMhlDiamVl: formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? '-', + wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-', + vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-', + altdVl: formattedItem.altdVl ?? item.altdVl ?? '-', + dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? '-' + }; + } + case 'tb_ms_swage_pmstn': { + // 하수펌프장 + return { + fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-', + ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-', + sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-', + swagePmstnNm: formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? '-', + instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-', + dailyPrcsCpct: formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? '-', + maxImpndCpct: formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? '-', + altdVl: formattedItem.altdVl ?? item.altdVl ?? '-', + wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? '-', + exclnWpmpAbltVl: formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? '-' + }; + } + case 'tb_ms_stofcbr': { + // 우수토실 + return { + fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-', + mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? '-', + ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-', + sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-', + instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-', + worflwWallHgt: formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? '-', + swageMhlMngNo: formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? '-', + drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? '-' + }; + } + } + }); + + // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 + const excelData = mappedContent; // 배열로 감싸서 전달 + + // 파일명 생성 (분석번호 기반) + const label = BASIC_TYPES.find((type) => type.val === basicType)?.text + const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`; + + await downloadExcelWithKoreanHeaders({ + data: excelData as JSON[], + fileName: fileName, + sheetNameArray: [fileName], + headerRange: headerRange, + headerMapping: headerMapping + }); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + console.error("Excel 다운로드 중 오류 발생:", error); + alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요."); + } + } + }; + + + return { fetchBasicFilteredData, handleBasicCellClick, basicExcelDownload }; +}; diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/html/(map-layout)/map/searchModal/useOperationTab.ts new file mode 100644 index 000000000..669dd4cb5 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/useOperationTab.ts @@ -0,0 +1,452 @@ +/** + * 📄 useOperationTab Hook + * 검색 모달 유지관리 정보 탭 훅 + * + * - 필터링된 사업 현황 데이터 조회 및 포맷팅 + * - 사업 현황 테이블 셀 클릭 시 상세 결과표 조회 + * - 결과표 셀 클릭 시 지도 피처 선택 및 줌 기능 + * - 엑셀 다운로드 기능 (사업 현황 및 결과표) + * - CQL 필터 생성을 통한 쿼리 조건 설정 + * - Zustand를 통한 상태 관리 및 로딩 처리 + * - 데이터 매핑 및 코드 변환 (사업 유형, 용도, 재질 등) + * + * 🔗 사용 API + * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) + * - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service) + */ +import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui'; +import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from '../components/common/table'; +import { getData } from "@/components/lib/api"; +import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { confirmToast } from '@/components/ui/toast' +import { toast } from "react-hot-toast"; +import useSearchModalStore from '@/store/useSearchModalStore'; +import useStore from '@/store/useStore'; + + +export default function useOperationTab() { + const OPERATION_TABLE_SIZE = 5; + + const { + workType, + tableData, detailTableData, + curPage, detailCurPage, + detailBizId, + var1, var2, + yearFilter, + todayYear, todayMonth, todayDate, + setIsSearchModalOpen, + setTableData, setDetailTableData, + setTableCount, setDetailTableCount, + setDetailCurPage, + setIsLoading, setIsDetailLoading, + setIsResetDetailPage, + setIsResetDetailSelection, + setDetailBizId, + getDateString + } = useSearchModalStore(); + const { BIZ_TY_CD } = useStore(); + + const WORK_TYPES = [ // 유지관리 정보 '구분' 선택 목록 + { val: "['200', '201', '204']", text: "보수 및 갱신" }, + { val: "['100']", text: "점검 및 진단" }, + { val: "['203']", text: "청소 및 준설" }, + ]; + const OPERATION_TABLE_HEADLIST = [ // 유지관리 정보 - 사업 현황 + { id: 'rowNumber', title: '일련번호' }, + { id: "bizInfoNm", title: "사업명" }, + { id: "flfmtBgngYmd", title: "수행시작일자" }, + { id: "flfmtEndYmd", title: "수행종료일자" }, + { id: "flfmtYr", title: "수행년도" }, + { id: "bizTypeCd", title: "사업유형" }, + { id: "bizAmt", title: "사업금액" }, + ]; + const MAINTENANACE_TABLE_HEADLIST = [ // 유지관리 정보 - 보수 및 갱신 결과표 + { id: 'rowNumber', title: '일련번호' }, + { id: "fcltMngNo", title: "관리번호" }, + { id: "swtrmt_zone_nm", title: "처리구역" }, + { id: "usgCd", title: "용도" }, + // { id: "lgalDrblyr", title: "내용연수" }, + // { id: "acqsPrmpc", title: "취득금액" }, + { id: "mqltCd", title: "재질" }, + { id: "pdmtVl", title: "관경(mm)" }, + { id: "prlgVl", title: "연장(m)" }, + { id: "instlYmd", title: "설치일자" }, + // { id: "instlYmd", title: "사업구분" }, + ]; + const CLEANING_TABLE_HEADLIST = [ // 유지관리 정보 - 청소 및 준설 결과표 + { id: 'rowNumber', title: '일련번호' }, + { id: "fcltMngNo", title: "관리번호" }, + { id: "swtrmt_zone_nm", title: "처리구역" }, + { id: "usgCd", title: "용도" }, + // { id: "lgalDrblyr", title: "내용연수" }, + // { id: "acqsPrmpc", title: "취득금액" }, + { id: "mqltCd", title: "재질" }, + { id: "pdmtVl", title: "관경(mm)" }, + { id: "prlgVl", title: "연장(m)" }, + { id: "instlYmd", title: "설치일자" }, + ]; + const INSPECTION_TABLE_HEADLIST = [ // 유지관리 정보 - 점검 및 진단 결과표 + { id: 'rowNumber', title: '일련번호' }, + { id: "fcltMngNo", title: "관리번호" }, + { id: "swtrmt_zone_nm", title: "처리구역" }, + { id: "usgCd", title: "용도" }, + // { id: "riskEval", title: "위험평가" }, + // { id: "acqsPrmpc", title: "취득금액" }, + // { id: "lgalDrblyr", title: "내용연수" }, + // { id: "remLife", title: "잔존수명" }, + { id: "mqltCd", title: "재질" }, + { id: "pdmtVl", title: "관경(mm)" }, + { id: "prlgVl", title: "연장(m)" }, + { id: "instlYmd", title: "설치일자" }, + { id: "strc_stts_grd", title: "구조적 상태등급" }, + { id: "oper_stts_grd", title: "운영적 상태등급" }, + ]; + + const fetchOperationFilteredData = async () => { + const RequestDTO: Record = { + page: `${curPage - 1}`, + size: `${OPERATION_TABLE_SIZE}`, + }; + if (var1) RequestDTO.bizInfoNm = var1; + if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2; + // 수행기간 범위 + const startDate = getDateString( + yearFilter.start, + '', + '', + "19000101", + ); + const endDate = getDateString( + yearFilter.end, + '', + '', + `${todayYear}${todayMonth}${todayDate}`, + ); + RequestDTO.fromflfmtEndYmd = startDate; + RequestDTO.toflfmtEndYmd = endDate; + + // JSON.parse 사용으로 문자열 -> 배열 변환 + RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); + + const params = new URLSearchParams(RequestDTO).toString(); + + const res = await getData(`v1/bizInfo?${params}`); + + const mappedContent = (res.content || []).map((item: any, index: number) => { + const formattedItem = formatDisplayData(item); + const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd); + return { + ...formattedItem, + rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가 + bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, + }; + }); + + setTableData(mappedContent || []); + setTableCount(res.totalElements || 0); + setDetailTableData([]); + setDetailTableCount(0); + }; + + // 유지관리 정보 테이블 cellClick 핸들러 + const handleOperationCellClick = async (index?: number | undefined, headId?: String) => { + setIsResetDetailPage(true); // 페이지를 1로 초기화 + setIsResetDetailSelection(true); // 선택 초기화 + setIsResetDetailPage(true); // 디테일 페이지를 초기화 + + if (typeof index === "number") { + setIsDetailLoading(true); + setDetailBizId(tableData[index].bizId); + setDetailCurPage(1); // 상세 페이지 초기화 + try { + const RequestDTO: Record = { + page: `${detailCurPage - 1}`, + size: `${OPERATION_TABLE_SIZE}`, + bizId: tableData[index % OPERATION_TABLE_SIZE].bizId, + }; + + RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); + + const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); + + const mappedData = (res.data || []).map((item: any, detailIndex: number) => { + const formattedItem = formatDisplayData(item); + + const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim()); + const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()); + return { + ...formattedItem, + rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1, + usgCd: usgCdObj ? usgCdObj.text : item.usgCd, + mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, + swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-', + }; + }); + + setDetailTableData(mappedData); + setDetailTableCount(res.count || 0); + } catch (error) { + console.error('유지관리 정보 cellClick 에러:', error); + } finally { + setIsDetailLoading(false); + setIsResetDetailPage(false); + setIsResetDetailSelection(false); + } + } + }; + + // 유지관리 정보 디테일 테이블 cellClick 핸들러 + const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => { + if (typeof index === "number") { + setIsLoading(true); + + const row = detailTableData[index]; + const layerNm = row.lyrTypeNm; + const fid = row.fid; + const core = GisApp.Module.layer as any; + await core.showSearchResultsFromGeoJSON(layerNm, fid); + setIsLoading(false); + setIsSearchModalOpen(false); // SearchModal 닫기 + } + }; + + // 유지관리 정보 디테일 테이블 페이지네이션 핸들러 + const handleOperationPageNation = async (index?: number | undefined) => { + if (typeof index === "number") { + setIsDetailLoading(true); + try { + const RequestDTO: Record = { + page: `${detailCurPage - 1}`, + size: `${OPERATION_TABLE_SIZE}`, + bizId: String(detailBizId), + }; + const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); + + const mappedData = (res.data || []).map((item: any, detailIndex: number) => { + const formattedItem = formatDisplayData(item); + + const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim()); + const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()); + return { + ...formattedItem, + rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1, + usgCd: usgCdObj ? usgCdObj.text : item.usgCd, + mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, + swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-', + }; + }); + + setDetailTableData(mappedData); + setDetailTableCount(res.count || 0); + } catch (error) { + console.error('유지관리 정보 페이지네이션 에러:', error); + } finally { + setIsDetailLoading(false); + } + } + }; + + // 유지관리 정보 Excel 다운로드 핸들러 추가 + const operationExcelDownload = async () => { + if (tableData.length === 0) { + alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요."); + return; + } + + toast.dismiss("global-confirm-toast"); + + confirmToast( + "파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?", + onAnalysis ?? (() => { }), + "text-xl font-medium text-gray-800" + ); + + async function onAnalysis() { + setIsLoading(true); + + try { + const RequestDTO: Record = { + size: '10000', + }; + if (var1) RequestDTO.bizInfoNm = var1; + if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2; + // 수행기간 범위 + const startDate = getDateString( + yearFilter.start, + '', + '', + "19000101", + ); + const endDate = getDateString( + yearFilter.end, + '', + '', + `${todayYear}${todayMonth}${todayDate}`, + ); + RequestDTO.fromflfmtEndYmd = startDate; + RequestDTO.toflfmtEndYmd = endDate; + + // JSON.parse 사용으로 문자열 -> 배열 변환 + RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); + + const params = new URLSearchParams(RequestDTO).toString(); + const res = await getData(`v1/bizInfo?${params}`); + + // 헤더 매핑 정의 + const headerMapping: { [key: string]: string } = { + 'bizInfoNm': '사업명', + 'flfmtBgngYmd': '수행시작일자', + 'flfmtEndYmd': '수행종료일자', + 'flfmtYr': '수행년도', + 'bizTypeCd': '사업유형', + 'bizAmt': '사업금액(원)', + }; + + const bizTyCdMap = new Map(BIZ_TY_CD.map(code => [code.lwpoCd, code.lwpoCdNm])); + + const mappedContent = (res.content || []).map((item: any) => { + const formattedItem = formatDisplayData(item); + + return { + bizInfoNm: formattedItem.bizInfoNm ?? item.bizInfoNm ?? '', + flfmtBgngYmd: formattedItem.flfmtBgngYmd ?? item.flfmtBgngYmd ?? '', + flfmtEndYmd: formattedItem.flfmtEndYmd ?? item.flfmtEndYmd ?? '', + flfmtYr: formattedItem.flfmtYr ?? item.flfmtYr ?? '', + bizTypeCd: bizTyCdMap.get(item.bizTypeCd) || item.bizTypeCd || formattedItem.bizTypeCd || '', + bizAmt: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.bizAmt ?? item.bizAmt), + }; + }); + + // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 + const excelData = mappedContent; // 배열로 감싸서 전달 + + // 파일명 생성 (분석번호 기반) + const label = WORK_TYPES.find((type) => type.val === workType)?.text + const fileName = `${label} 사업 현황_${todayYear}${todayMonth}${todayDate}`; + + await downloadExcelWithKoreanHeaders({ + data: excelData as JSON[], + fileName: fileName, + sheetNameArray: [fileName], + headerRange: "A1:F1", + headerMapping: headerMapping + }); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + console.error("Excel 다운로드 중 오류 발생:", error); + alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요."); + } + } + }; + + // 유지관리 정보 결과표 Excel 다운로드 핸들러 추가 + const operationResultExcelDownload = async () => { + if (detailTableData.length === 0) { + alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요."); + return; + } + + toast.dismiss("global-confirm-toast"); + + confirmToast( + "파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?", + onAnalysis ?? (() => { }), + "text-xl font-medium text-gray-800" + ); + + async function onAnalysis() { + + setIsLoading(true); + + try { + const RequestDTO: Record = { + size: '100', + bizId: String(detailBizId), + }; + + const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); + + // 헤더 매핑 정의 + const headerMapping: { [key: string]: string } = { + 'fcltMngNo': '관리번호', + 'swtrmt_zone_nm': '처리구역', + 'usgCd': '용도', + 'mqltCd': '재질', + 'pdmtVl': '관경(mm)', + 'prlgVl': '연장(m)', + 'instlYmd': '설치일자', + 'strc_stts_grd': '구조적 상태등급', + 'oper_stts_grd': '운영적 상태등급', + }; + + const usgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val, code.text])); + const mqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val, code.text])); + + // 🎯 단일 반복문으로 최적화 - 각 아이템마다 필요한 필드만 직접 매핑 + const mappedData = (res.data || []).map((item: any) => { + const formattedItem = formatDisplayData(item); + + // trim 한 번만 수행 + const trimmedUsgCd = item?.usgCd?.trim(); + const trimmedMqltCd = item?.mqltCd?.trim(); + + // zone 값 한 번만 계산 + const zone = item.swtrmtZoneNm ?? formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? ''; + + // 직접 매핑 - 반복문 없이 한 번에 객체 생성 + return { + fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '', + swtrmt_zone_nm: (zone === null || zone === '') ? '-' : zone, + usgCd: usgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '', + mqltCd: mqltCdMap.get(trimmedMqltCd) || item.mqltCd || formattedItem.mqltCd || '', + pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '', + prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '', + instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '', + strc_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.strc_stts_grd ?? item.strc_stts_grd), + oper_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.oper_stts_grd ?? item.oper_stts_grd), + }; + }); + + // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 + const excelData = mappedData; // 배열로 감싸서 전달 + + // 파일명 생성 (분석번호 기반) + const label = WORK_TYPES.find((type) => type.val === workType)?.text + const fileName = `${label} 결과표_${todayYear}${todayMonth}${todayDate}`; + + await downloadExcelWithKoreanHeaders({ + data: excelData as JSON[], + fileName: fileName, + sheetNameArray: [fileName], + headerRange: "A1:I1", + headerMapping: headerMapping + }); + + setIsLoading(false); + } catch (error) { + setIsLoading(false); + console.error("Excel 다운로드 중 오류 발생:", error); + alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요."); + } + } + }; + + + return { + WORK_TYPES, + OPERATION_TABLE_HEADLIST, + MAINTENANACE_TABLE_HEADLIST, + CLEANING_TABLE_HEADLIST, + INSPECTION_TABLE_HEADLIST, + OPERATION_TABLE_SIZE, + fetchOperationFilteredData, + handleOperationCellClick, + handleOperationResultCellClick, + handleOperationPageNation, + operationExcelDownload, + operationResultExcelDownload + }; +}; \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/useReportTab.ts b/apps/sn/app/html/(map-layout)/map/searchModal/useReportTab.ts new file mode 100644 index 000000000..8ffe06de8 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/useReportTab.ts @@ -0,0 +1,188 @@ +/** + * 📄 useReportTab Hook + * 검색 모달 보고서 탭 훅 + * + * - 계층적 폴더 구조 평탄화 및 데이터 변환 + * - 파일 목록 조회 및 페이징 처리 + * - 파일 다운로드 기능 + * - 셀 클릭 이벤트 처리 + * - Zustand를 통한 상태 관리 및 로딩 처리 + * - 데이터 매핑 및 코드 변환 (시도/시군구, 보고서 유형 등) + * + * 🔗 사용 API + * - 보고서 파일 조회: GET /api/v1/file (dc-service) + * - 파일 다운로드: GET /api/v1/fileDownload (dc-service) + */ +import { getDcData } from '@/components/lib/api'; +import { fileDownload } from '@/utils'; +import { useReportTabStore } from '@/store/useReportTabStore'; +import useSearchModalStore from '@/store/useSearchModalStore'; + +export type Folder = { + clsfSystmSn: string; + upClsfSystmSn: string; + clsfSystmNm: string; + children: Folder[]; +}; +export type FlattenedFolder = { + [key: string]: string | number | undefined; +}; +export type SelectDataType = { + text: string; + val: string; + sido?: string; // 시도 코드 (선택 사항) +} +export type ReportDataFile = { + no: number; + fileTitle: string; + regDt: string; + fileSn: string; + filePath: string; +} + +export default function useReportTab() { + const REPORT_TYPES = [ + { val: '0', text: '전체' }, + { val: '1', text: '보고서' }, + { val: '2', text: '요약보고서' }, + { val: '3', text: '부록' }, + { val: '4', text: '기타' } + ]; + + const { curPage, setIsLoading } = useSearchModalStore(); + const { dcFiles: filesOrigin, files, setFiles } = useReportTabStore(); + + /** + * Flatten a hierarchical folder structure into a flat array. + * @param data - The hierarchical folder data to flatten. + * @returns An array of flattened folders with their paths and levels. + */ + const flattenFolders = (data: Folder[]): FlattenedFolder[] => { + const result: FlattenedFolder[] = []; + const exceptNodeNm = ["부분", "통합", "전체"]; + const addNodeNm = ["보고서", "요약보고서", "부록", "기타"]; // level5 + const columnNms: { [key: number]: string } = { + 0: "root", 1: "sido", 2: "sigungu", 3: "approvalYear" + , 4: "plantZoneGubun", 5: "reportGubun", 7: "fileName", 8: "regDate" + }; + + const traverse = ( + node: Folder, + path: { [key: string]: string | number }, + level: number + ) => { + const isYear = (value: string): boolean => { + return /^[0-9]{4}년$/.test(value); + }; + + if (level === 2 && isYear(node.clsfSystmNm)) { + level++; + } + if (level === 4 && addNodeNm.includes(node.clsfSystmNm)) { + level++; // 레벨을 증가시켜 하위값에 추가되도록 함. + } + + const newPath = { + ...path, + [`${columnNms[level]}`]: node.clsfSystmNm, + }; + + if (node.children.length === 0) { + newPath.no = result.length + 1; + newPath.clsfSystmSn = node.clsfSystmSn; // 파일 조회를 위한 고유번호 + result.push(newPath); + } else { + if (level === 4) { + if (exceptNodeNm.includes(node.clsfSystmNm)) { + level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함. + } + } + node.children.forEach((child) => { + traverse(child, newPath, level + 1); + }); + } + } + + if (data && data.length > 0 && data[0]?.clsfSystmNm == "기본계획") { // data가 비어있지 않고 children이 있는 경우에만 처리 + data.forEach((root) => traverse(root, {}, 0)); + } else { + data.forEach((root) => traverse(root, {}, 1)); + } + return result; + } + + const getDcFiles = async (clsfSystmSn: string): Promise => { + if (!clsfSystmSn) { + console.error("clsfSystmSn is required to get files."); + return []; + } + const queryString = 'clsfSystmSn=' + clsfSystmSn; + const result = await getDcData(`/api/v1/file${queryString ? `?${queryString}` : ''}`); + return result; + }; + + // 파일 이름 추가 + const addFileName = async (data: FlattenedFolder[]) => { + const fileReportData = await Promise.all( + data.map(async (item) => { + const fileArr: ReportDataFile[] = await getDcFiles(String(item.clsfSystmSn)); + let result: FlattenedFolder; + if (fileArr.length > 0) { + const f = fileArr[0]; + const fCount = fileArr.length; + result = { ...item, fileName: f?.fileTitle + " 외 " + fCount + "건", regDate: f?.regDt, fileSn: f?.fileSn, filePath: f?.filePath }; + } else { + result = { ...item, fileName: "", regDate: "", fileSn: "", filePath: "" }; + } + return result; + }) + ); + return fileReportData; + }; + + const getDcFileDownload = async (fileSn: string, fileName: string, filePath: string) => { + if (filePath === "") { + alert(`filePath 정보가 없으므로 다운로드가 불가합니다.\nfilesn: ${fileSn}`); + return; + } + const queryString = 'filePath=' + encodeURIComponent(filePath); + const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, "blob"); + fileDownload(responseBlob, fileName); + } + + // 파일 목록 조회 + const getFiles = async () => { + setIsLoading(true); // 로딩 시작 + try { + let data = filesOrigin; + data = data?.slice((curPage - 1) * 10, curPage * 10); // paging + data = data?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 })); + setFiles(data); + } catch (error) { + console.error("파일 목록 조회 에러:", error); + } finally { + setIsLoading(false); // 로딩 종료 + } + }; + + // cellClick 핸들러 + const handleCellClick = (index: number | undefined) => { + if (typeof index === "number" && files[index]) { + const fileName = files[index].fileTitle; + const filePath = files[index].filePath; + if (files[index].fileSn !== "") { + getDcFileDownload(files[index].fileSn, fileName, filePath); + } + } + }; + + return { + REPORT_TYPES, + flattenFolders, + addFileName, + getDcFiles, + getDcFileDownload, + getFiles, + handleCellClick + }; +} diff --git a/apps/sn/app/html/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/html/(map-layout)/map/searchModal/useSearchModal.ts new file mode 100644 index 000000000..7e4b2c9dd --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/searchModal/useSearchModal.ts @@ -0,0 +1,161 @@ +/** + * 📄 useSearchModal Hook + * 검색 모달 훅 + * + * - 탭 기반 검색 인터페이스 관리 (기본정보, 유지관리 정보, 보고서) + * - 필터링된 데이터 조회 및 포맷팅 + * - 조회 버튼을 통한 데이터 검색 트리거 + * - 여러 시설 보기 기능 + * - Zustand를 통한 상태 관리 및 로딩 처리 + * - 데이터 매핑 및 코드 변환 (사업 유형 등) + * + * 🔗 사용 API + * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) + */ +import { getData } from "@/components/lib/api"; +import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; +import useStore from "@/store/useStore"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import useSearchModalStore from "@/store/useSearchModalStore"; +import useBasicTab from './useBasicTab'; +import useOperationTab from './useOperationTab'; + +export default function useSearchModal() { + const { + activeTab, workType, + tableData, detailTableData, + curPage, + var1, var2, + yearFilter, + todayYear, todayMonth, todayDate, + setIsSearchModalOpen, + setShowTable, + setTableData, setDetailTableData, + setTableCount, setDetailTableCount, + setCurPage, + setIsLoading, + setIsResetPage, setIsResetDetailPage, + setIsResetSelection, setIsResetDetailSelection, + getDateString + } = useSearchModalStore(); + const { fetchBasicFilteredData } = useBasicTab(); + const { OPERATION_TABLE_SIZE } = useOperationTab(); + const { BIZ_TY_CD } = useStore(); + + const TABS = [ + { val: "basic", text: "기본 정보" }, + { val: "operation", text: "유지관리 정보" }, + { val: "report", text: "하수도 정비 기본계획 보고서" }, + ]; + + // 검색 API 호출 함수 + const fetchFilteredData = async () => { + setIsLoading(true); // 로딩 시작 + setIsResetSelection(true); // 선택 초기화 + setIsResetDetailSelection(true); // 선택 초기화 + + try { + if (activeTab === 'basic') { + fetchBasicFilteredData(); + + } else if (activeTab === 'operation') { + const RequestDTO: Record = { + page: `${curPage - 1}`, + size: `${OPERATION_TABLE_SIZE}`, + }; + if (var1) RequestDTO.bizInfoNm = var1; + if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2; + // 수행기간 범위 + const startDate = getDateString( + yearFilter.start, + '', + '', + "19000101", + ); + const endDate = getDateString( + yearFilter.end, + '', + '', + `${todayYear}${todayMonth}${todayDate}`, + ); + RequestDTO.fromflfmtEndYmd = startDate; + RequestDTO.toflfmtEndYmd = endDate; + + // JSON.parse 사용으로 문자열 -> 배열 변환 + RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); + + const params = new URLSearchParams(RequestDTO).toString(); + + const res = await getData(`v1/bizInfo?${params}`); + + const mappedContent = (res.content || []).map((item: any, index: number) => { + const formattedItem = formatDisplayData(item); + const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd); + return { + ...formattedItem, + rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가 + bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, + }; + }); + + setTableData(mappedContent || []); + setTableCount(res.totalElements || 0); + setDetailTableData([]); + setDetailTableCount(0); + } + + setShowTable(true); + setIsResetSelection(false); + } catch (err) { + console.error('API ERROR:', err); + setTableData([]); + setIsResetSelection(false); + } finally { + setIsLoading(false); // 로딩 종료 + setIsResetSelection(false); + // setIsResetDetailSelection(false); + } + }; + + // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 + const handleSearch = () => { + setCurPage(1); // 페이지를 1로 초기화 + fetchFilteredData(); + setIsResetPage(true); // 페이지를 초기화 + setIsResetDetailPage(true); // 디테일 페이지를 초기화 + setIsResetSelection(false); // 선택 초기화 + setIsResetDetailSelection(false); // 선택 초기화 + setShowTable(true); + }; + + // 여러 시설 보기 + const viewMultipleFeature = async () => { + if (activeTab === 'basic') { + if (!(tableData.length === 0)) { + setIsLoading(true); + + const row = tableData[0]; + const layerNm = row.lyrTypeNm; + const fids = tableData.map((item) => item.fid); + const core = GisApp.Module.layer as any; + await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); + setIsLoading(false); + setIsSearchModalOpen(false); + } + } else if (activeTab === 'operation') { + if (!(detailTableData.length === 0)) { + setIsLoading(true); + + const row = detailTableData[0]; + const layerNm = row.lyrTypeNm; + const fids = detailTableData.map((item) => item.fid); + const core = GisApp.Module.layer as any; + await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); + setIsLoading(false); + setIsSearchModalOpen(false); + } + } + } + + return { TABS, getDateString, handleSearch, viewMultipleFeature, fetchFilteredData }; +}; diff --git a/apps/sn/app/html/(map-layout)/map/waterDepth/waterDepth.tsx b/apps/sn/app/html/(map-layout)/map/waterDepth/waterDepth.tsx new file mode 100644 index 000000000..4ea769ff7 --- /dev/null +++ b/apps/sn/app/html/(map-layout)/map/waterDepth/waterDepth.tsx @@ -0,0 +1,98 @@ +/** + * ➡️ 경로 : 하수관망도 메인페이지 내 침수심 범례 + * + * 📄 WaterDepth Component + * 침수심 범례 컴포넌트 + * + * - 침수심 레이어의 범례 표시 및 애니메이션 재생 제어 + * - 진행률 바를 통한 애니메이션 상태 시각화 + * - 플레이/일시정지 버튼을 통한 사용자 상호작용 + * - 범례 항목별 색상과 레이블 표시 + */ + +"use client"; + +import { Label } from "@common/components/ui/label"; +import { useEffect } from "react"; + +export const WATER_DEPTH_LAYER_TYPES: string[] = [ + 'tb_ms_flood_dngr_map', // 홍수위험지도 + 'tb_ms_sbmrg_dngr_map' // 침수위험지도 +]; + +interface WaterDepthProps { + onClose?: () => void; + isPlaying: boolean; + setIsPlaying: (isPlaying: boolean) => void; + totalSteps?: number; + currentStep?: number; +} + +export function WaterDepth({ + onClose, + isPlaying, + setIsPlaying, + totalSteps = 0, + currentStep = 0, +}: WaterDepthProps) { + + useEffect(() => { + if (setIsPlaying) setIsPlaying(isPlaying); + }, [isPlaying, setIsPlaying]); + + const handlePlayToggle = () => { + setIsPlaying(!isPlaying); + }; + + // 진행률 계산 (0-100%) + const progressPercentage = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 100; + + + return ( + <> +
+
+ + +
+
+
+
+
+
+
+
침 수 심
+
+ {/* 코드: N330 */} + {/* 코드: N331 */} + {/* 코드: N332 */} + {/* 코드: N333 */} + {/* 코드: N334 */} +
+
+
+
+ + ); +} + +function LegendItem({ color, label }: { color: string; label: string }) { + return ( +
+ + {label} +
+ ); +} \ No newline at end of file diff --git a/apps/sn/app/html/(with-base-layout)/layout.tsx b/apps/sn/app/html/(with-base-layout)/layout.tsx new file mode 100644 index 000000000..346521d17 --- /dev/null +++ b/apps/sn/app/html/(with-base-layout)/layout.tsx @@ -0,0 +1,59 @@ +/** + * ➡️ 경로 : I/I분석, 유지관리 정보 모든 페이지 + * + * 📄 With Base Layout + * Next.js App Router (app/) 기반 분석 페이지 기본 레이아웃 컴포넌트 + * + * - 메뉴 클릭 이벤트 처리 및 라우팅 + * - 현재 메뉴 정보 Zustand를 통한 상태 관리 + * - 시도/시군구/사업 구분/사업 유형 데이터 초기 로드 + * - BaseLayout을 통한 공통 레이아웃 적용 + * - QueryClient를 통한 데이터 캐싱 및 관리 + * - 경로 변경 시 메뉴 정보 동기화 + */ +"use client"; + +import React, { useEffect} from 'react'; +import { usePathname, useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { BaseLayout } from "@common/components"; +import type { MenuItem } from "@/entities/menu"; +import { useMenuStore } from "@/shared/lib"; +import { PageLayout } from "@/shared/ui/page-layout"; +import useStore from '../store/useStore'; + +export default function WithBaseLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + + const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); + + const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore(); + + useEffect(() => { + fetchCtpv(); + fetchSgg(); + fetchBIZ_SE_CD(); + fetchBIZ_TY_CD(); + }, []); + + const handleMenuClick = (item: MenuItem) => { + setCurrentMenuInfo(item); + router.push(item.urlPath); + }; + + return ( + + {children} + + ); +} diff --git a/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md b/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md new file mode 100644 index 000000000..2e05f8e89 --- /dev/null +++ b/apps/sn/app/html/06. 노후관로관리_시나리오_v1.0.md @@ -0,0 +1,97 @@ +# 노후관로 관리 + +--- + +## 화면구성 +### GIS 기본화면 +-노후관로 조건에 맞지 않는 관로를 숨기지 않고, 기존 시설물 레이어는 원래대로 모두 보이게 두고 조건에 맞는 관로만 빨간 강조선을 추가하도록 한다. +- 좌측 상단 (시설물 레이어 트리 및 목록 아이콘): 시설, 침수위험지도, 지반침하정보(우측에 목록 아이콘), 노후관로(우측에 목록 아이콘) +- 좌측 하단 (레이어별 설정 항목 및 적용 버튼): 경과년도(년) [ 디폴트 30년 ] +- 우측 영역(배경 지도 및 시설레이어 표출): 하수관로 중 경과년도가 30년 이상된 관로들을 시각적으로 강조 표시 + +### 관로정보 상세화면 +- GIS 화면에서 관로 위치 클릭시 우측 정렬로 세로형 영역으로 관로정보 상세화면 팝업 +- 1차년도에서 개발한 관로정보 표출 : 시설관리번호, 기관관리번호, 설치일자, 도엽번호 등 전체 항목 그대로 표출 + +### 노후관로정보 목록화면 +- 윈도우형 팝업 +- GIS 기본화면 좌측 상단의 레이어 트리에서 노후관로 목록 아이콘 클릭시 윈도우 팝업으로 가운데 영역으로 노후관로정보 목록화면 팝업 +- 상단 조회조건( 조회조건 영역 ) : 경과년도, 시도, 시군구, 버튼 2개 (조회, 다운로드) +- 하단 가로형 그리드 (노후관로 현황) : NO, 시설관리번호, 설치일자, 관경(mm), 연장(m), 용도, 재질, 경과년도 + +--- + +## 화면동작 + +### 초기화 및 기본 설정 + +```구조적표기 +// 화면 로드 시 초기 데이터 및 환경 설정 +ON_LOAD: + CALL GIS_지도_레이어_초기화() + SET 기본_경과년도 = 20 // 회의록(260311) 반영 디폴트 설정 + SET 현재_조회_조건 = { "경과년도": 20, "관종": "전체" } + + // 초기 데이터 로드 + CALL 노후관로_데이터_조회(현재_조회_조건) + CALL 지도_상_노후관로_표출(조회결과) +``` + +### 좌측 사이드바 제어 (분석 설정) + +```구조적표기 +// 노후관로 조회 조건 변경 및 적용 +IF 노후관로_조회_버튼.CLICK THEN + SET 입력_경과년도 = 사이드바.경과년도_INPUT.Value + SET 선택_관종 = 사이드바.관종_SELECT.Value + + // 공간 및 속성 필터링 실행 + CALL 노후관로_필터링_프로세스(입력_경과년도, 선택_관종) + REFRESH_GIS_LAYER(노후관로_레이어) + SHOW MESSAGE "설정된 조건으로 노후관로가 필터링되었습니다." +END IF + +// 목록 아이콘 클릭 (모달 호출) +IF 레이어트리.노후관로_목록아이콘.CLICK THEN + DISPLAY 노후관로_목록_모달 = TRUE + CALL 노후관로_목록_그리드_로드() +END IF +``` + +#### 노후관로 상세화면 (우측 패널) + +```구조적표기 +// 지도 상의 특정 관로 클릭 시 +IF GIS.노후관로_객체.CLICK THEN + SET 선택관로_ID = 클릭객체.ID + + // 상세 속성 정보 조회 및 매핑 + CALL 관로_상세속성_API_호출(선택관로_ID) + SET 우측패널.기본정보 = [관로ID, 시설명, 주소, 관리기관] + SET 우측패널.제원정보 = [관경, 연장, 관종, 준공일자, 경과년도] + SET 우측패널.상태정보 = [최종조사일, 조사결과등급] + + DISPLAY 우측_상세_패널 = TRUE +END IF +``` + +#### 노후관로 목록 및 데이터 출력 (모달 윈도우) +```구조적표기 +// 엑셀 다운로드 (회의록 요구사항 반영) +IF 모달.다운로드_버튼.CLICK THEN + // 엑셀 파일에는 관로의 '모든 기본정보'를 포함 + SET 다운로드_컬럼 = [관로ID, 주소, 관경, 연장, 관종, 준공일, 경과년도, ...전체필드] + CALL 시스템_엑셀_추출_모듈(현재_조회_데이터, 다운로드_컬럼) +END IF + +// 목록 그리드 행 선택 시 로직 +IF 모달.그리드_행.DOUBLE_CLICK THEN + SET 좌표 = 현재행.중심좌표 + CALL GIS_지도_줌인(좌표) + CALL 노후관로_상세정보_표출(현재행.ID) + CLOSE_MODAL(노후관로_목록_모달) +END IF +``` + + + diff --git a/apps/sn/app/html/AGENTS.md b/apps/sn/app/html/AGENTS.md new file mode 100644 index 000000000..348d1c2d1 --- /dev/null +++ b/apps/sn/app/html/AGENTS.md @@ -0,0 +1,27 @@ +## html 디렉토리 +- 이 디렉토리의 목적은 코드를 화면 publishing과 로직 설정을 위한 폴더이다. +- 작업시 html 디렉토리 이외의 수정이 되지 않도록 해야 한다. +- 추가 *.md 파일의 요청대로 만들되 상위의 를 건드리지 않고 apps\sn\app\html 에 추가파일을 만들어서 처리한다. +- 기존의 package/common/component 와 package/common/business_component를 잘 활용한다. +- 의 시설물 layer는 api 호출 결과 값 설정된 대로 zoom level에 따라 모두 보여야 한다. +## Encoding +- UTF-8 +- 한글이 깨지지 않도록 주의할 것 + +## 주석 comment +- 한글 주석을 삭제하지 말 것 + +## Monorepo structure +apps/ + _template + as + cm + cs + dc + dm + dm_new + fo + sn + st + wr + diff --git a/apps/sn/package.json b/apps/sn/package.json index 471cbe0cb..1041df157 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -12,6 +12,7 @@ "@common/assets": "workspace:*", "@common/business_components": "workspace:*", "@common/components": "workspace:*", + "@common/config": "workspace:*", "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", diff --git a/apps/wr/.env.local b/apps/wr/.env.local index db59d33c4..19f7f5a24 100644 --- a/apps/wr/.env.local +++ b/apps/wr/.env.local @@ -12,8 +12,8 @@ NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver -NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 -#NEXT_PUBLIC_WR_SERVER_API_URL = http://192.168.1.222:8883/reuse-service/api/v1 +# NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 +NEXT_PUBLIC_WR_SERVER_API_URL = http://192.168.1.222:8883/reuse-service/api/v1 NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API # NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr diff --git a/apps/wr/AGENTS.md b/apps/wr/AGENTS.md index 7eefb1676..5b690843c 100644 --- a/apps/wr/AGENTS.md +++ b/apps/wr/AGENTS.md @@ -21,6 +21,7 @@ Main technologies: ## 주석 comment - 한글 주석을 삭제하지 말 것 +- 신규 component나 함수 자동 생성시 주석을 추가할 것(기본 함수의 역할과 인수 설명) ## Monorepo structure apps/ diff --git a/apps/wr/app/(map-layout)/map/mainMap/page.tsx b/apps/wr/app/(map-layout)/map/mainMap/page.tsx index a03810b96..a2addf109 100644 --- a/apps/wr/app/(map-layout)/map/mainMap/page.tsx +++ b/apps/wr/app/(map-layout)/map/mainMap/page.tsx @@ -386,7 +386,6 @@ const Map = () => { }; }, [checkedLayers]); - console.log(checkedLayers); return (
- - - 시설명 - - 하수처리량 -
- (㎥/일) - - - 재이용량 -
- (㎥/일) - - 삭제 - - - - {data.map((item) => ( - - {item.ruseUtztnFcltNm} - - {Number(item.splyExtraCpct || 0).toLocaleString()} - - - {Number(item.ruseCpct || 0).toLocaleString()} - - - - - - ))} - - - ); - - if (data.length === 0) return null; - return (

@@ -73,7 +30,53 @@ export default function SelectedFcltList({

- {TableContent} + + + + + + + + + + {data.length > 0 ? ( + data.map((item) => ( + + + + + + + )) + ) : ( + + + + )} +
시설명 + 하수처리량 +
+ (㎥/일) +
+ 재이용량 +
+ (㎥/일) +
삭제
{item.ruseUtztnFcltNm} + {Number(item.splyExtraCpct || 0).toLocaleString()} + + {Number(item.ruseCpct || 0).toLocaleString()} + + +
+ 선택된 시설이 없습니다. +
diff --git a/apps/wr/app/(with-layout)/matMng/coMat/coMatRvw/page.tsx b/apps/wr/app/(with-layout)/matMng/coMat/coMatRvw/page.tsx index bcc168aac..330847f86 100644 --- a/apps/wr/app/(with-layout)/matMng/coMat/coMatRvw/page.tsx +++ b/apps/wr/app/(with-layout)/matMng/coMat/coMatRvw/page.tsx @@ -1,6 +1,6 @@ "use client"; /** - * 📄 CoMatRvw 수요매칭 검토 + * 📄 CoMatRvw 수요처 매칭 검토 * Next.js App Router (app/) 기반 페이지 컴포넌트 예시 * * - 대민에서 신청하는 수요처의 수요등록 정보를 운영담당자가 수요 매칭 검토를 진행한다. @@ -8,7 +8,7 @@ */ import { useWrStore } from "@/app/store"; import { btnClass } from "@/app/store/common/code"; -import { usageProcData } from "@/app/store/matDmd/coMatRvw/headList"; +import { usageProcData } from "@/app/store/matDmd/coMatRvw/headList"; // 용도에 따른 처리공정 import { DmdCoReqSchema, type DmdCoReqType, @@ -112,19 +112,29 @@ export default function CoMatRvw() { if (coMatRvwRes && coMatRvwRes[index]) { setSelectData(coMatRvwRes[index]); setSelectionVersion((v) => v + 1); // 행을 클릭할 때마다 버전을 1씩 증가 - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // 화면 스크로 처리 } }; const useUsgCdWatch = useWatch({ control: form.control, name: "useUsgCd" }); useEffect(() => { if (useUsgCdWatch) { + console.log("useUsgCdWatch:" , useUsgCdWatch); + const normalizedUseUsgCd = + code.sewageOutFclttUse?.find((item) => item.val === useUsgCdWatch)?.val ?? + code.sewageOutFclttUse?.find((item) => item.text === useUsgCdWatch)?.val ?? + useUsgCdWatch; + + if (normalizedUseUsgCd !== useUsgCdWatch) { + form.setValue("useUsgCd", normalizedUseUsgCd); + } + form.setValue( "demandCst.usgPrcsProcs", - usageProcData?.find((item) => item.usage === useUsgCdWatch)?.process ?? + usageProcData?.find((item) => item.useUsgCd === normalizedUseUsgCd)?.process ?? "" ); } - }, [useUsgCdWatch, form, selectionVersion]); + }, [useUsgCdWatch, form, selectionVersion, code.sewageOutFclttUse]); useEffect(() => { if (coMatRvwRes && coMatRvwRes.length > 0) { diff --git a/apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx b/apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx index 86e72ea7f..872284783 100644 --- a/apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx +++ b/apps/wr/app/(with-layout)/matMng/coMng/coSttn/page.tsx @@ -1,5 +1,8 @@ "use client"; +/** + * 수요처관리 + */ import { useWrStore } from "@/app/store"; import { btnClass } from "@/app/store/common/code"; import { excelCoSttnParsing } from "@/app/store/matDmd/coSttn/excel"; @@ -188,6 +191,47 @@ export default function CoSttn() { } } + /** + * 등록/수정 : SdmInsertForm에서 저장 버튼을 눌렀을 때, 폼 데이터가 부모로 넘어오는 콜백 + * @param data + * @returns + */ + const handleRtnInputData = async (data: DemandCompType) => { + const con = + objSearch.sgg && objSearch.sgg !== "0" + ? objSearch.sgg + : objSearch.city; + + if (updateData?.dmdInfoId) { // 수정 + const message = await updateDemandCompany(data); + alert(message); + coSttnInit("1", "13", String(con)); + setIsDmdModal({ + isMoal: false, + data, + }); + setIsResetSelection(true); + return; + } + + const dmd = await getFetch( + fetchType.WR, + `/reuseFc/request/${data.dmdInfoId}` + ); + if (dmd) { + alert(`사업자번호 ${dmd.dmdInfoId}는 이미 등록되었습니다. 다시등록해주세요`); + return; + } + + const message = await regDemandCompany(data); // 등록 + alert(message); + coSttnInit("1", "13", String(con)); + setIsDmdModal({ + isMoal: false, + data, + }); + }; + return ( <> @@ -284,12 +328,12 @@ export default function CoSttn() { headlist={coSttn.coSttHeadList} pageSize={13} total={coSttnResCount || 0} - cellClick={ async(index: number | undefined) => { - const uuid = await getMyUUID(); - const updateData = coSttnRes[index as number]; - if (updateData && typeof uuid === "string") { - updateData.mdfr = uuid; - } + cellClick={async(index: number | undefined) => { + const uuid = await getMyUUID(); + const updateData = coSttnRes[index as number]; + if (updateData && typeof uuid === "string") { + updateData.mdfr = uuid; + } setUpdateData(coSttnRes[index as number]); }} onPageChange={(page) => { @@ -321,38 +365,7 @@ export default function CoSttn() { rtnValue={(searchText: string, pageCnt: number) => { getDemandCompList(searchText, pageCnt); }} - rtnInputData={async (data) => { - const con = - objSearch.sgg && objSearch.sgg !== "0" - ? objSearch.sgg - : objSearch.city; - if (updateData?.dmdInfoId) { - const message = await updateDemandCompany(data as DemandCompType); - alert(message); - coSttnInit("1", "13", String(con)); - setIsDmdModal({ - isMoal: false, - data: data as DemandCompType, - }); - setIsResetSelection(true); - } else { - const dmd = await getFetch( - fetchType.WR, - `/reuseFc/request/${data.dmdInfoId}` - ); - if (dmd) { - alert(`사업자번호 ${dmd.dmdInfoId}는 이미 등록되었습니다. 다시등록해주세요`); - return; - } - const message = await regDemandCompany(data as DemandCompType); - alert(message); - coSttnInit("1", "13", String(con)); - setIsDmdModal({ - isMoal: false, - data: data as DemandCompType, - }); - } - }} + rtnInputData={handleRtnInputData} bodyClassNm={"w-full mx-auto bg-white rounded-lg shadow-md p-6"} updateList={updateData} openNetwork={true} diff --git a/apps/wr/app/layout.tsx b/apps/wr/app/layout.tsx index edd57b02b..e26635d4f 100644 --- a/apps/wr/app/layout.tsx +++ b/apps/wr/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { App } from "../src/app"; +import { setCookie } from "../src/shared/lib/cookie"; +setCookie("site-id-key", process.env.NEXT_PUBLIC_SITE_ID || "WR"); export const metadata: Metadata = { title: "물재이용매칭시스템", description: "물재이용매칭 시스템입니다", diff --git a/apps/wr/app/loading.tsx b/apps/wr/app/loading.tsx index 80b268890..73624ba56 100644 --- a/apps/wr/app/loading.tsx +++ b/apps/wr/app/loading.tsx @@ -1,4 +1,5 @@ export default function Loading() { - return
페이지 불러오는 중입니다...
; + // return
페이지 불러오는 중입니다...
; + return <> } \ No newline at end of file diff --git a/apps/wr/app/store/common/auth/index.tsx b/apps/wr/app/store/common/auth/index.tsx index b553f0d41..e213bafa3 100644 --- a/apps/wr/app/store/common/auth/index.tsx +++ b/apps/wr/app/store/common/auth/index.tsx @@ -1,7 +1,8 @@ import { fetchType, getFetchWithCount } from "@/components/lib/api"; import { getUserInfo } from "@common/components/api"; import { getCookie } from "@common/components/lib"; -import toast from "react-hot-toast"; +// import toast from "react-hot-toast"; +import { redirect } from 'next/navigation'; export type authState = { auth: { @@ -59,10 +60,11 @@ export const authActionsExport: (set: any, get: any) => authActions = ( }; }); } catch (e) { - toast.error( - "권한 정보를 가져오는 중 오류가 발생했습니다. 관리자에게 문의하세요." - ); + // toast.error( + // "권한 정보를 가져오는 중 오류가 발생했습니다. 관리자에게 문의하세요." + // ); console.error("Error fetching menu authorities:", e); + redirect("/"); } }, getLaeSeCdCheck: async () => { diff --git a/apps/wr/app/store/common/code/index.ts b/apps/wr/app/store/common/code/index.ts index f6e28339c..fb400de2c 100644 --- a/apps/wr/app/store/common/code/index.ts +++ b/apps/wr/app/store/common/code/index.ts @@ -77,6 +77,7 @@ export const getCommonUpCd = async (set: any) => { pplQulity: lwCdCode["pplQulity"] ?? [], pplLenght: lwCdCode["pplLenght"] ?? [], dmdUszCd: lwCdCode["dmdUszCd"] ?? [], + sewageOutFclttUse: lwCdCode["sewageOutFclttUse"] ?? [], ruseMngMtd: lwCdCode["ruseMngMtd"] ?? [], rusePrcsMthdCd: lwCdCode["rusePrcsMthdCd"] ?? [], erctUswtrPrcsMeth: lwCdCode["erctUswtrPrcsMeth"] ?? [], @@ -233,6 +234,7 @@ export type comCodeState = { pplQulity: SelectDataType[]; pplLenght: SelectDataType[]; dmdUszCd: SelectDataType[]; + sewageOutFclttUse: SelectDataType[]; buildingType: { RST: string; RNB: string; @@ -274,6 +276,7 @@ export const comCodeStateExport: comCodeState = { pplLenght: lwCds["pplLenght"] ?? [], yn: yn, dmdUszCd: lwCds["dmdUszCd"] ?? [], + sewageOutFclttUse: lwCds["sewageOutFclttUse"] ?? [], buildingType: buildingType, ruseMngMtd: lwCds["ruseMngMtd"] ?? [], ruseSeCd: ruseSeCd, diff --git a/apps/wr/app/store/common/searchCode/index.ts b/apps/wr/app/store/common/searchCode/index.ts index 1478b10a8..276271d60 100644 --- a/apps/wr/app/store/common/searchCode/index.ts +++ b/apps/wr/app/store/common/searchCode/index.ts @@ -39,7 +39,8 @@ export const comFcltStateExport: comFcltState = { cdtnv: 0, crmty: 0, rmrk: "", - reqDt: "" + reqDt: "", + zip: "", }, constCostRes:[], rateFeeRes:[], @@ -51,7 +52,7 @@ export type comFcltState = { searchFclts: pubHasuType[]; demandCompList: DemandCompType[]; checkDemand: DemandCompType; - constCostRes:conCostFeeType[], + constCostRes:conCostFeeType[], // 표준사업비 rateFeeRes:rateFeeType[], }; @@ -149,6 +150,7 @@ export const comFcltSearchAction: (set: any) => comFcltActions = (set: any) => { return "수요처 정보 변경 중 오류가 발생했습니다."; } }, + // 요율정보 조회 : ONSEMIRO_NEW.EM_STD_COST_INC_EXP_RT 설치관리표준사업비 부대경비요율 getRateFee: async (year: string) => { try { const url = `/prcsFclt/CstRt/${year}`; @@ -164,7 +166,9 @@ export const comFcltSearchAction: (set: any) => comFcltActions = (set: any) => { } catch (e) { console.log(" error ", e); } - },getConstCostFee: async (year: string) => { + }, + // 공사금액 조회 : ONSEMIRO_NEW.EM_STD_COST_REUSE 설치관리표준사업비 재이용시설 공사비 + getConstCostFee: async (year: string) => { try { const url = `/prcsFclt/CstRuse/${year}`; const constCost = await getFetch(fetchType.COMMON, url); @@ -176,7 +180,7 @@ export const comFcltSearchAction: (set: any) => comFcltActions = (set: any) => { } }); - }catch (e) { + } catch (e) { console.log('getConstCostFee ', e) } diff --git a/apps/wr/app/store/matDmd/coMatRvw/headList.tsx b/apps/wr/app/store/matDmd/coMatRvw/headList.tsx index 16d2107c6..895ba5f89 100644 --- a/apps/wr/app/store/matDmd/coMatRvw/headList.tsx +++ b/apps/wr/app/store/matDmd/coMatRvw/headList.tsx @@ -187,13 +187,11 @@ export const rateFeeHeadListExport: CommonTableProps[] = [ ]; export const usageProcData = [ - { usage: "청소", process: "총인처리시설+염소소독" }, - { usage: "화장실용수", process: "총인처리시설+염소소독" }, - { usage: "세척", process: "총인처리시설+UV소독" }, - { usage: "살수", process: "총인처리시설+UV소독" }, - { usage: "조경", process: "총인처리시설+UV소독" }, - { usage: "농업", process: "총인처리시설+UV소독" }, - { usage: "하천", process: "총인처리시설+UV소독" }, - { usage: "지하수", process: "전처리시설+RO" }, - { usage: "공업", process: "전처리시설+RO" }, + { useUsgCd: "01", usage: "청소화장실용수", process: "총인처리시설+염소소독" }, + { useUsgCd: "02", usage: "세척살수용수", process: "총인처리시설+UV소독" }, + { useUsgCd: "03", usage: "조경용수", process: "총인처리시설+UV소독" }, + { useUsgCd: "05", usage: "지하수충전", process: "전처리시설+RO" }, + { useUsgCd: "06", usage: "농업용수", process: "총인처리시설+UV소독" }, + { useUsgCd: "07", usage: "하천등유지용수", process: "총인처리시설+UV소독" }, + { useUsgCd: "08", usage: "공업용수", process: "전처리시설+RO" }, ]; diff --git a/apps/wr/app/store/matDmd/coMatRvw/index.tsx b/apps/wr/app/store/matDmd/coMatRvw/index.tsx index 0c58db9a6..39d1eaa28 100644 --- a/apps/wr/app/store/matDmd/coMatRvw/index.tsx +++ b/apps/wr/app/store/matDmd/coMatRvw/index.tsx @@ -139,6 +139,7 @@ export const coRvwActionsExport: (set: any) => coRvwActions = (set: any) => { return "매칭 검토 저장 오류입니다."; } }, + // 수요처 매칭 검토 현황 coMatRvwSearch: async ( pageNo: number, pageCnt: number, @@ -160,7 +161,7 @@ export const coRvwActionsExport: (set: any) => coRvwActions = (set: any) => { url += `/0`; } - console.log(" url ", url); + // console.log(" url ", url); const coMatRvwRes = await getFetchWithCount(fetchType.WR, url); return set((state: coRvwState) => ({ diff --git a/apps/wr/components/lib/func/calculateConstCost .tsx b/apps/wr/components/lib/func/calculateConstCost .tsx index 5e9387385..2c2ec48ae 100644 --- a/apps/wr/components/lib/func/calculateConstCost .tsx +++ b/apps/wr/components/lib/func/calculateConstCost .tsx @@ -1,3 +1,18 @@ +/** + * 주어진 코드와 수량 기준으로 보간 계산에 사용할 하한/상한 구간 데이터를 찾는다. + * + * 같은 코드(`fctCd`)를 가진 데이터만 추린 뒤, + * `qtyCode` 값이 현재 수량(`qty`) 이하인 항목 중 가장 큰 값을 `lower`, + * 초과인 항목 중 가장 작은 값을 `upper`로 반환한다. + * 한쪽 경계만 존재하면 같은 값을 상/하한에 보정해 반환한다. + * + * @param data 전체 기준 데이터 목록 + * @param fctCd 찾고 싶은 시설/구분 코드 값 + * @param dataCode 코드 비교에 사용할 객체 속성명 + * @param qtyCode 수량 비교에 사용할 객체 속성명 + * @param qty 현재 비교 기준 수량 + * @returns 보간 계산에 사용할 하한(lower), 상한(upper) 데이터 + */ export const findBounds = ( data: any[], fctCd: string, diff --git a/apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx b/apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx index cfb388264..ca75016cf 100644 --- a/apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx +++ b/apps/wr/components/ui/form/coMat/conCostForm/conCostForm.tsx @@ -20,6 +20,7 @@ import { Form, FormField, FormUi, + SelectBox, UiTable, } from "@common/business_components/ui"; import PopoverUi from "@common/business_components/ui/popover_ui"; @@ -47,12 +48,13 @@ export default function ConCostForm({ readonly: boolean; }) { const { - constCostRes, - rateFeeRes, + constCostRes, // 표준사업비 + rateFeeRes, // 부대경비요율 coMatRvwUpdate, ruseRvwCalUpdate, getRateFee, getConstCostFee, + code, } = useWrStore((state) => state); const alert = useAlertStore((state) => state.setMessage); @@ -107,8 +109,16 @@ export default function ConCostForm({ const totalConstCost = form.watch("totalConstCost"); const cstrnCst = form.watch("demandCst.cstrnCst"); const splyList = form.watch("splyCndtList"); + + /** + * 표준공산비 산정 : "계산"버튼 click시 + */ const calculateConstCost = useCallback(() => { const usgPrcsProcs = usgPrcsProcsWatch; + if (selectionVersion == 0) { + alert("먼저 목록에서 수요신청 건을 선택하세요."); + return; + } if (!dmndQtyWatch) { alert("요구수량(m³/일)을 확인해주세요 계산할수 없습니다. "); return; @@ -119,23 +129,30 @@ export default function ConCostForm({ ); return; } + if (constCostRes?.length === 0) { + alert("표준공사비 데이터가 없습니다."); + return; + } + if (rateFeeRes?.length === 0) { + alert("표준공사비 데이터가 없습니다."); + return; + } const dmndQtyYear = dmndQtyWatch; if (usgPrcsProcs && usgPrcsProcs?.length > 0) { - const firstProc = - getConstFctCode(usgPrcsProcs?.split("+")[0] || "") || ""; - const secondProc = - getConstFctCode(usgPrcsProcs?.split("+")[1] || "") || ""; + const firstProc = getConstFctCode(usgPrcsProcs?.split("+")[0] || "") || ""; // 첫번째 공정 + const secondProc = getConstFctCode(usgPrcsProcs?.split("+")[1] || "") || ""; // 두번째 공정 + // 첫번째 처리공정 코드에 해당하는 표준공사비 테이블이서 요구수량을 감싸는 하한/상한 구간을 찾는 부분이다. const { lower: firstLower, upper: firstUpper } = findBounds( - constCostRes, + constCostRes, // 표준공사비 firstProc || "", "fctCd", "fctCpc", Number(dmndQtyYear) ); const { lower: secondLower, upper: secondUpper } = findBounds( - constCostRes, + constCostRes, // 표준공사비 secondProc || "", "fctCd", "fctCpc", @@ -158,7 +175,7 @@ export default function ConCostForm({ const groupedBounds: { [key: string]: { lower?: any; upper?: any } } = {}; for (const code of groupCodes) { groupedBounds[code] = findBounds( - rateFeeRes, + rateFeeRes, // 부대경비요율 code, "incExpCd", "stdCost", @@ -175,8 +192,15 @@ export default function ConCostForm({ form.setValue("demandCst.prcsFcltCpctCstrnCst", Math.round(result)); form.setValue("demandCst.fcltSbsdCstrnCst", Math.round(fcltSbsdCstrnCst)); form.setValue("totalConstCost", Math.round(realResult)); + alert("표준공산비 산정이 완료되었습니다."); } }, [constCostRes, rateFeeRes, usgPrcsProcsWatch, dmndQtyWatch]); + + /** + * 저장 버튼 click 시 실행 + * @param value + * @returns + */ const onSubmit = async (value: DmdCoReqType) => { if (value.demandCst && !value.demandCst.prcsFcltCpctCstrnCst) { alert("공사비 계산 진행 후 저장을 눌러주새요"); @@ -188,7 +212,7 @@ export default function ConCostForm({ // ? splycd[0]?.ruseUtztnFcltCd // : "11000SW001R1"; - const pk =lastSub?.ruseUtztnFcltCd??null + const pk =lastSub?.ruseUtztnFcltCd??null; // 후보지 if (type === "mat") { if (value.demandCst) { @@ -295,10 +319,17 @@ export default function ConCostForm({ }, []); + /** + * 후보지 조회 + * @param e + */ const searchCandidate = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + if (selectionVersion == 0) { + alert("먼저 목록에서 수요신청 건을 선택하세요."); + return; + } const dmdInfoId = form.getValues("dmdInfoId"); console.log(dmdInfoId) @@ -365,14 +396,17 @@ export default function ConCostForm({ name="useUsgCd" render={({ field }) => ( - { - field.onChange(e.target.value); + { + if (readonly) return; + field.onChange(String(val)); }} value={field.value ?? ""} - readOnly={true} + readOnly={readonly} /> )} diff --git a/packages/common/business_components/src/ui/form/index.tsx b/packages/common/business_components/src/ui/form/index.tsx index d2f955244..fc9bf5b8c 100644 --- a/packages/common/business_components/src/ui/form/index.tsx +++ b/packages/common/business_components/src/ui/form/index.tsx @@ -42,22 +42,24 @@ const FormField = < } const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState, formState } = useFormContext() + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState) + // 필드명으로 필드의 상태를 가져옴. + // errors, isDirty, dirtyFields, touchedFields, isSubmitted, isSubmitSuccessful, isValid, defaultValues, disabled + const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { throw new Error("useFormField should be used within ") } - const { id } = itemContext + const { id } = itemContext; return { id, name: fieldContext.name, - formItemId: `${id}-form-item`, + formItemId: `${id}-form-item`, // 접근성 속성 연결용 formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, @@ -86,6 +88,8 @@ const FormItem = React.forwardRef< }) FormItem.displayName = "FormItem" +// React.forwardRef를 사용해서 부모가 Label DOM ref를 직접 받을 수 있게 하고, +// 현재 FormField의 에러 상태와 form item id를 연결해 접근성(htmlFor)과 에러 스타일을 함께 처리한다. const FormLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef diff --git a/packages/common/business_components/src/ui/map/lib/gis.style.js b/packages/common/business_components/src/ui/map/lib/gis.style.js index f38853962..4ad75c386 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.style.js +++ b/packages/common/business_components/src/ui/map/lib/gis.style.js @@ -206,7 +206,7 @@ GisOlStyle.prototype = { element: overlayContainer, positioning: 'bottom-center', stopEvent: false, - offset: overLayOffset ? overLayOffset : [80, 0], // 오프셋 설정 (위치 조정) + offset: overLayOffset ? overLayOffset : [65, 0], // 오프셋 설정 (위치 조정) }); this.overlays[overLayId] = overlay; diff --git a/packages/common/business_components/src/ui/sdmInsertForm/form.tsx b/packages/common/business_components/src/ui/sdmInsertForm/form.tsx index b2499ab43..2cedb1164 100644 --- a/packages/common/business_components/src/ui/sdmInsertForm/form.tsx +++ b/packages/common/business_components/src/ui/sdmInsertForm/form.tsx @@ -38,6 +38,7 @@ export type DemandCompType = { crmty: number | ""; rmrk: string | ""; reqDt: string | ""; + zip: string | ""; }; type SelectDataType = { @@ -50,9 +51,10 @@ interface FormSchema { sggCd: string; dmdCoNm: string; dmdInfoId: string; + zip?: string; daddr: string; telno: string; - bznmTotNo?: string; + bznmTotNo?: string; // 종사업자등록번호 rgtr?: string | ""; mdfr?: string | null; dmdAplyPswd?: string; @@ -62,9 +64,9 @@ interface FormSchema { interface SdmRegFormProps { form: UseFormReturn; onCheckboxChange?: (checked: boolean) => void; - selectSidoData: SelectDataType[]; - selectSigunData: SelectDataType[]; - dataList: DemandCompType[]; + selectSidoData: SelectDataType[]; //시/도 데이터 + selectSigunData: SelectDataType[]; //시/군/구 데이터 + dataList: DemandCompType[]; //수요처 목록 rtnValue: (searchText: string, pageCnt: number) => void; setIsDuplicate: (value: boolean) => void; updateList?: DemandCompType; @@ -97,7 +99,7 @@ export function SdmRegForm({ const [orgBznmNo, setOrgBznmNo] = useState(""); const [updateCoNm, setUpdateNm] = useState(""); const [isZipModalOpen, setIsZipModalOpen] = useState(false); - const [zipId, setZipId] = useState(undefined); + const [zoneNo, setZoneNo] = useState(""); const [address, setAddress] = useState(""); const [detailedAddress, setDetailedAddress] = useState(""); @@ -219,6 +221,10 @@ export function SdmRegForm({ handleCheckboxChange(false); } + setZoneNo(rtnCompData?.zip || ""); + form.setValue("zip", rtnCompData?.zip || ""); + setAddress(rtnCompData?.daddr || ""); + setDetailedAddress(""); form.setValue("daddr", rtnCompData?.daddr || ""); form.setValue("telno", rtnCompData?.telno || ""); @@ -235,6 +241,47 @@ export function SdmRegForm({ [form], ); + const applyZipRegion = useCallback( + (ctpvNm?: string, sggNm?: string) => { + const normalizeRegionName = (value?: string) => + value?.replace(/\s+/g, "").trim() ?? ""; + + const sidoName = normalizeRegionName(ctpvNm); + const sigunName = normalizeRegionName(sggNm); + if (!sidoName) return; + + const matchedSido = selectSidoData.find( + (item) => normalizeRegionName(item.text) === sidoName, + ); + if (!matchedSido) return; + + setSidoValue(matchedSido.val); + form.setValue("ctpvCd", matchedSido.val, { shouldValidate: true }); + initializeSigunData(matchedSido.val); + + if (!sigunName) return; + + const sigunCandidates = selectSigunData.filter( + (item) => item.val.substring(0, 2) === matchedSido.val, + ); + + const matchedSigun = + sigunCandidates.find( + (item) => normalizeRegionName(item.text) === sigunName, + ) ?? + sigunCandidates.find((item) => { + const itemName = normalizeRegionName(item.text); + return itemName.includes(sigunName) || sigunName.includes(itemName); + }); + + if (!matchedSigun) return; + + setSigunValue(matchedSigun.val); + form.setValue("sggCd", matchedSigun.val, { shouldValidate: true }); + }, + [form, initializeSigunData, selectSidoData, selectSigunData], + ); + const bznNoChange = useCallback( ( e: React.ChangeEvent, @@ -275,6 +322,10 @@ export function SdmRegForm({ handleCheckboxChange(true); } form.setValue("dmdInfoId", updateList?.dmdInfoId.slice(0, 10)); + setZoneNo(updateList?.zip || ""); + form.setValue("zip", updateList?.zip || ""); + setAddress(updateList?.daddr || ""); + setDetailedAddress(""); setUpdateNm(updateList?.dmdCoNm); // setSigunValue(updateList?.sggCd) } else { @@ -295,6 +346,10 @@ export function SdmRegForm({ } }, [address, detailedAddress, form]); + useEffect(() => { + form.setValue("zip", zoneNo, { shouldValidate: true }); + }, [form, zoneNo]); + const formFields: Array<{ name: keyof FormSchema; @@ -427,10 +482,10 @@ export function SdmRegForm({ type="text" className="!text-xl text-gray-600 w-64" readOnly={true} - value={zipId?.toString() ?? ""} + value={form.watch("zip") ?? ""} placeholder="우편번호" /> - +
{/* 주소 */} { - setZipId(zip.zipId); + setZoneNo(zip.zoneNo || ""); setAddress(zip.zipAddr || ""); setDetailedAddress(""); + form.setValue("zip", zip.zoneNo || "", { shouldValidate: true }); + applyZipRegion(zip.ctpvNm, zip.sggNm); }} /> diff --git a/packages/common/business_components/src/ui/sdmInsertForm/index.tsx b/packages/common/business_components/src/ui/sdmInsertForm/index.tsx index ef9dbeed2..70ee0958e 100644 --- a/packages/common/business_components/src/ui/sdmInsertForm/index.tsx +++ b/packages/common/business_components/src/ui/sdmInsertForm/index.tsx @@ -32,6 +32,7 @@ const createFormSchema = (isBznmTotNoDisabled: boolean, isDuplicate: boolean ,up .min(1, "사업자등록번호를 입력해주세요") .regex(/^\d{10}$/, "사업자등록번호는 10자리 숫자만 입력 가능합니다") .refine(() => isBznmTotNoDisabled ? !isDuplicate : true, "이미 등록된 사업자등록번호입니다"), + zip: z.string().optional(), bznmTotNo: isBznmTotNoDisabled ? z.string().optional() : z.string() @@ -73,6 +74,7 @@ const defaultValues: FormSchema = { sggCd: '', dmdCoNm: '', dmdInfoId: '', + zip: '', bznmTotNo: '', daddr: '', telno: '', @@ -88,13 +90,13 @@ const defaultValues: FormSchema = { * @param selectSigunData 시/군/구 데이터 * @param dataList 수요처 목록 * @param rtnValue 검색 결과(텍스트) 콜백 - * @param rtnInputData 수요처 리스트 콜백 + * @param rtnInputData 수요처관리 콜백 : 등록/수정 처리 * @returns */ +export default function SdmInsertForm({selectSidoData, selectSigunData, dataList, rtnValue, rtnInputData + , bodyClassNm, updateList, readOnly, openNetwork}: thisProps) { -export default function SdmInsertForm({selectSidoData, selectSigunData, dataList, rtnValue, rtnInputData,bodyClassNm, updateList,readOnly,openNetwork}: thisProps) { - - const [isBznmTotNoDisabled, setIsBznmTotNoDisabled] = useState(false); + const [isBznmTotNoDisabled, setIsBznmTotNoDisabled] = useState(false); // 종사업자등록번호 해당없음 const [isDuplicate, setIsDuplicate] = useState(false); const formSchema = createFormSchema(isBznmTotNoDisabled, isDuplicate, updateList ); @@ -144,7 +146,7 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList /* await insertSdmData(formData); */ - rtnInputData(formData as unknown as DemandCompType); + rtnInputData(formData as unknown as DemandCompType); // 수요처 등록/수정 처리 //router.push(`/pages/sdmMng/sdmInfoInsert?${queryParams.toString()}`); @@ -157,7 +159,7 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList <>

- {readOnly==true? '수요처 정보':'수요처 등록'} + {!updateList? '수요처 등록' :readOnly==true? '수요처 정보':'수요처 수정'}

@@ -194,4 +196,4 @@ export default function SdmInsertForm({selectSidoData, selectSigunData, dataList ); -} \ No newline at end of file +} diff --git a/packages/common/business_components/src/ui/search_address/SearchAddressModal.tsx b/packages/common/business_components/src/ui/search_address/SearchAddressModal.tsx index 699d3cc50..bd4db9e7d 100644 --- a/packages/common/business_components/src/ui/search_address/SearchAddressModal.tsx +++ b/packages/common/business_components/src/ui/search_address/SearchAddressModal.tsx @@ -1,9 +1,17 @@ -import { Button, Input, Label, cn } from "@common/components"; +/** + * 주소검색 + */ +import { Button, Input, cn } from "@common/components"; import React, { useState, useEffect } from "react"; import { UiModal } from "../dialog_ui"; import { UiTable } from "../table"; -import type { Zip, ZipSearchParams } from "./zipService"; -import { findAddress } from "./findAddress"; +import { + fetchPostcodeSuggestions, + findAddress, + parseZipSearchInput, + type Zip, + type ZipSearchParams, +} from "./zipService"; interface SearchAddressModalProps { open: boolean; @@ -11,20 +19,35 @@ interface SearchAddressModalProps { onSelect: (zip: Zip) => void; } +/** + * 주소 검색 값 감시 + * @param value + * @param delay + * @returns + */ +function useDebouncedValue(value: T, delay = 250) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeoutId = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timeoutId); + }, [delay, value]); + + return debouncedValue; +} + export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddressModalProps) { const PAGE_SIZE = 10; const [curPage, setCurPage] = useState(1); + const [searchInput, setSearchInput] = useState(""); + const debouncedSearchInput = useDebouncedValue(searchInput, 250); const [zipSearchResults, setZipSearchResults] = useState([]); - const [zipTotalElements, setZipTotalElements] = useState(-1); - const [searchQuery, setSearchQuery] = useState({ - roadNm: "", - bldgNoMno: null, - bldgNoSno: null, - page: 1, - size: PAGE_SIZE - }); + const [zipTotalElements, setZipTotalElements] = useState(-1); //주소 검색 결과 건수 + const [searchMessage, setSearchMessage] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(true); + const [searchQuery, setSearchQuery] = useState(undefined); const [isResetPage, setIsResetPage] = useState(false); - const [isResetSelection] = useState(false); const [selectedRowIndex, setSelectedRowIndex] = useState(undefined); const [selectedZip, setSelectedZip] = useState(); const [isSelectedZip, setIsSelectedZip] = useState(false); @@ -33,15 +56,13 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre useEffect(() => { if (open) { setCurPage(1); + setSearchInput(""); setZipSearchResults([]); setZipTotalElements(-1); - setSearchQuery({ - roadNm: "", - bldgNoMno: null, - bldgNoSno: null, - page: 0, - size: PAGE_SIZE - }); + setSearchMessage(""); + setSuggestions([]); + setShowSuggestions(true); + setSearchQuery(undefined); setSelectedZip(undefined); setIsSelectedZip(false); setSelectedRowIndex(undefined); @@ -50,42 +71,119 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre } }, [open]); + useEffect(() => { + if (!open) return; + + const keyword = debouncedSearchInput.trim(); + if (!keyword) { + setSuggestions([]); + return; + } + if (!showSuggestions) { + setSuggestions([]); + return; + } + + let alive = true; + + const fetchSuggestions = async () => { + try { + const nextSuggestions = await fetchPostcodeSuggestions(keyword); + if (!alive) return; + setSuggestions(nextSuggestions); + } catch { + if (!alive) return; + setSuggestions([]); + } + }; + + void fetchSuggestions(); + + return () => { + alive = false; + }; + }, [debouncedSearchInput, open, showSuggestions]); + // Pagination Effect useEffect(() => { - if (!open || zipTotalElements === -1) return; + if (!open || zipTotalElements === -1 || !searchQuery) return; // 주소 검색 결과 건수 초기값이 -1일 때는 아직 검색 전 상태 // Correctly update query for pagination without mutation const nextPage = curPage - 1; if (searchQuery.page !== nextPage) { - const newQuery = { ...searchQuery, page: nextPage }; - setSearchQuery(newQuery); + const newQuery = { ...searchQuery, page: nextPage }; + setSearchQuery(newQuery); + setSelectedZip(undefined); + setIsSelectedZip(false); + setSelectedRowIndex(undefined); - const fetchAddress = async () => { + const fetchAddressByPage = async () => { + try { const result = await findAddress(newQuery); - setZipSearchResults(result?.content || []); - }; - fetchAddress(); + setSearchMessage(""); + setZipSearchResults(result?.content || []); // 주소검색결과 + } catch { + setZipSearchResults([]); + setSearchMessage("주소 검색 중 오류가 발생했습니다."); + } + }; + void fetchAddressByPage(); } - }, [curPage, open, zipTotalElements]); + }, [curPage, open, searchQuery, zipTotalElements]); - const handleCellClick = (index: any) => { - setSelectedZip(zipSearchResults[index]); + const handleCellClick = (index: number | undefined) => { + if (index === undefined) return; + const zip = zipSearchResults[index]; + if (!zip) return; + setSelectedZip(zip); setIsSelectedZip(true); setSelectedRowIndex(index); + onSelect(zip); + onOpenChange(false); }; const handleSearch = async (e: React.FormEvent) => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault(); // 폼 제출 동작을 막음. + e.stopPropagation(); // 이벤트 버블링을 막음. 이벤트가 부모 컴포넌트나 바깥쪽 핸들러로 올라가지 않도록 막음. + setShowSuggestions(false); + setSuggestions([]); - const query = { ...searchQuery, page: 0 }; + const parsed = parseZipSearchInput(searchInput, 1); + if (!parsed.ok) { + setSearchMessage(parsed.message); + setZipSearchResults([]); + setZipTotalElements(-1); + setSelectedZip(undefined); + setIsSelectedZip(false); + setSelectedRowIndex(undefined); + return; + } + + const query: ZipSearchParams = { + roadNm: parsed.value.roadNm, + bldgNoMno: parsed.value.bldgNoMno, + bldgNoSno: parsed.value.bldgNoSno, + page: parsed.value.page - 1, + size: PAGE_SIZE, + }; + + setSearchMessage(""); setSearchQuery(query); setCurPage(1); setIsResetPage(true); + setSelectedZip(undefined); + setIsSelectedZip(false); + setSelectedRowIndex(undefined); - const result = await findAddress(query); - setZipSearchResults(result?.content || []); - setZipTotalElements(result?.totalElements || 0); + try { + const result = await findAddress(query); + setZipSearchResults(result?.content || []); + setZipTotalElements(result?.totalElements ?? 0); //주소 검색 결과 건수 + } catch { + setZipSearchResults([]); + setZipTotalElements(-1); + setSearchMessage("주소 검색 중 오류가 발생했습니다."); + } setTimeout(() => setIsResetPage(false), 100); }; @@ -97,6 +195,8 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre } }; + const showSuggest = open && showSuggestions && searchInput.trim().length >= 1 && suggestions.length > 0; + return ( -
-
-
- - - setSearchQuery({ - ...searchQuery, - roadNm: e.target.value, - }) - } - placeholder="예: 테헤란로" - className="!text-[1.3rem]" - /> -
- -
- - { - const value = e.target.value - ? parseInt(e.target.value) - : null; - setSearchQuery({ - ...searchQuery, - bldgNoMno: value, - }); - }} - placeholder="예: 123" - className="!text-[1.3rem]" - /> -
- -
- - { - const value = e.target.value - ? parseInt(e.target.value) - : null; - setSearchQuery({ - ...searchQuery, - bldgNoSno: value, - }); - }} - placeholder="예: 45" - className="!text-[1.3rem]" - /> +
+
+
+
+ { + setSearchInput(e.target.value); + setShowSuggestions(true); + }} + placeholder="예) 테헤란로 152-1" + className="!text-[1.3rem]" + /> + {showSuggest && ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} +
+
- -
- +
+ {searchMessage || "도로명과 건물번호를 함께 입력해 주세요. 예) 테헤란로 152-1"}
@@ -197,7 +277,6 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre cellClick={handleCellClick} onPageChange={setCurPage} resetPageNo={isResetPage} - resetSelection={isResetSelection} index={selectedRowIndex} /> )} diff --git a/packages/common/business_components/src/ui/search_address/findAddress.ts b/packages/common/business_components/src/ui/search_address/findAddress.ts deleted file mode 100644 index 5e5ba4360..000000000 --- a/packages/common/business_components/src/ui/search_address/findAddress.ts +++ /dev/null @@ -1,118 +0,0 @@ - -// const Key = 'U01TX0FVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA' -// const addresssKey = 'devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM='; //인증키 만료일은 2025-11-23 - -import type { Zip, ZipSearchParams } from "./zipService"; - -/** - * 주소 API 응답 타입 - * https://business.juso.go.kr/addrlink/main.do 해당 사이트 참조 - */ -export type AddressApiResponse = { - results: { - common: { - errorMessage: string; - countPerPage: string; - totalCount: string; - errorCode: string; - currentPage: string; - }; - juso: addressType[]; - }; -} - -export type addressType = { - detBdNmList: string; - engAddr: string; - rn: string; - emdNm: string; - zipNo: string; - roadAddrPart2: string; - emdNo: string; - sggNm: string; - jibunAddr: string; - siNm: string; - roadAddrPart1: string; - bdNm: string; - admCd: string; - udrtYn: string; - lnbrMnnm: string; - roadAddr: string; - lnbrSlno: string; - buldMnnm: string; - bdKdcd: string; - liNm: string; - rnMgtSn: string; - mtYn: string; - bdMgtSn: string; - buldSlno: string; -} - -/** - * 주소에 따른 검색 - * @param search : 검색어 - * @returns addressType[] 주소 배열 - */ -export const findAddress = async (searchQuery: ZipSearchParams): Promise => { - if (!searchQuery) return []; - - // 쿼리 파라미터 생성 - const params = new URLSearchParams(); - if (searchQuery.roadNm) params.append('roadNm', searchQuery.roadNm); - if (searchQuery.bldgNoMno != null) params.append('bldgNoMno', searchQuery.bldgNoMno.toString()); - if (searchQuery.bldgNoSno != null) params.append('bldgNoSno', searchQuery.bldgNoSno.toString()); - if (searchQuery.page != null) params.append('page', searchQuery.page.toString()); - if (searchQuery.size != null) params.append('size', searchQuery.size.toString()); - - const url = `/cms-service/api/v1/zips?${params.toString()}`; - try { - const response = await fetch(url); - if (!response.ok) throw new Error('Network response was not ok'); - - const data = await response.json(); - if (data) return data - else return []; - } catch (error) { - console.error('Error fetching address data:', error); - return []; - } - -} - - - -export const searchFilter = (search: string) => { - - - if (search.length > 0) { - //특수문자 제거 - const expText = /[%=><]/; - if (expText.test(search) == true) { - alert("특수문자를 입력 할수 없습니다."); - search = search.split(expText).join(""); - return false; - } - - //특정문자열(sql예약어의 앞뒤공백포함) 제거 - const sqlArray = [ - //sql 예약어 - "OR", "SELECT", "INSERT", "DELETE", "UPDATE", "CREATE", "DROP", "EXEC", - "UNION", "FETCH", "DECLARE", "TRUNCATE" - ] - - let regex; - for (let i = 0; i < sqlArray.length; i++) { - regex = new RegExp(sqlArray[i] as string, "gi"); - - if (regex.test(search)) { - alert("\"" + sqlArray[i] + "\"와(과) 같은 특정문자로 검색할 수 없습니다."); - search = search.replace(regex, ""); - return false; - } - } - } - return true; - - - -} diff --git a/packages/common/business_components/src/ui/search_address/index.ts b/packages/common/business_components/src/ui/search_address/index.ts index b790bb492..60b20e82c 100644 --- a/packages/common/business_components/src/ui/search_address/index.ts +++ b/packages/common/business_components/src/ui/search_address/index.ts @@ -1,3 +1,2 @@ export * from "./SearchAddressModal"; -export * from "./findAddress"; export * from "./zipService"; diff --git a/packages/common/business_components/src/ui/search_address/zipService.ts b/packages/common/business_components/src/ui/search_address/zipService.ts index 03e4a8ccc..7cdba0992 100644 --- a/packages/common/business_components/src/ui/search_address/zipService.ts +++ b/packages/common/business_components/src/ui/search_address/zipService.ts @@ -64,3 +64,275 @@ export interface Zip { } export type ZipList = PageableList; + +export interface ParsedZipSearchParams { + roadNm: string; + bldgNoMno: number; + bldgNoSno: number | null; + page: number; +} + +export type ParseZipSearchInputResult = + | { ok: true; value: ParsedZipSearchParams; keyword: string } + | { ok: false; message: string }; + +type SuggestionSeed = { + roadNm: string; + bldgNoMno: number; +}; + +/** + * 주소 검색 API를 호출해 우편번호/주소 목록을 조회한다. + * @param searchQuery 조회 조건 객체 + * @param searchQuery.roadNm 검색할 도로명 + * @param searchQuery.bldgNoMno 건물 본번 + * @param searchQuery.bldgNoSno 건물 부번 + * @param searchQuery.page 0부터 시작하는 페이지 번호 + * @param searchQuery.size 페이지당 조회 건수 + * @returns 검색 결과 목록과 전체 건수를 포함한 페이징 응답 + */ +export async function findAddress(searchQuery: ZipSearchParams): Promise { + if (!searchQuery) return undefined; + + const params = new URLSearchParams(); + if (searchQuery.roadNm) params.append("roadNm", searchQuery.roadNm); + if (searchQuery.bldgNoMno != null) params.append("bldgNoMno", searchQuery.bldgNoMno.toString()); + if (searchQuery.bldgNoSno != null) params.append("bldgNoSno", searchQuery.bldgNoSno.toString()); + if (searchQuery.page != null) params.append("page", searchQuery.page.toString()); + if (searchQuery.size != null) params.append("size", searchQuery.size.toString()); + + const url = `/cms-service/api/v1/zips?${params.toString()}`; + const response = await fetch(url); + if (!response.ok) throw new Error("Network response was not ok"); + + return (await response.json()) as ZipList; +} + +/** + * 입력 문자열에서 자동완성 조회용 도로명만 추출한다. + * 마지막 토큰이 숫자 또는 숫자-숫자 형식이면 건물번호로 보고 제외한다. + * @param input 사용자가 입력창에 입력한 원본 문자열 + * @returns 자동완성 조회에 사용할 도로명 + */ +export function extractRoadNameForSuggestion(input: string): string { + const normalized = input.replace(/\s+/g, " ").trim(); + if (!normalized) return ""; + + const parts = normalized.split(" "); + const tail = parts[parts.length - 1] ?? ""; + + if (/^\d+(?:-\d+)?$/.test(tail) && parts.length > 1) { + return parts.slice(0, -1).join(" ").trim(); + } + + return normalized; +} + +/** + * 검색어에 허용되지 않는 특수문자나 SQL 예약어가 포함되어 있는지 검사한다. + * @param search 사용자가 입력한 검색 문자열 + * @returns 검색 가능하면 true, 차단 대상이면 false + */ +export function searchFilter(search: string): boolean { + if (search.length === 0) return true; + + const expText = /[%=><]/; + if (expText.test(search)) { + alert("특수문자를 입력 할수 없습니다."); + return false; + } + + const sqlArray = [ + "OR", + "SELECT", + "INSERT", + "DELETE", + "UPDATE", + "CREATE", + "DROP", + "EXEC", + "UNION", + "FETCH", + "DECLARE", + "TRUNCATE", + ]; + + for (const sqlWord of sqlArray) { + const regex = new RegExp(sqlWord, "gi"); + if (regex.test(search)) { + alert(`"${sqlWord}"와(과) 같은 특정문자로 검색할 수 없습니다.`); + return false; + } + } + + return true; +} + +/** + * 자동완성 후보 생성을 위해 주소 항목에서 도로명과 건물 본번을 추출한다. + * 우선 응답 필드를 사용하고, 값이 없으면 표시 주소 문자열을 파싱해 보정한다. + * @param item 주소 검색 결과 1건 + * @returns 자동완성 생성에 사용할 도로명/건물본번 또는 추출 실패 시 null + */ +export function extractSuggestionSeed(item: Zip): SuggestionSeed | null { + const mnoByField = Number(item.bldgNoMno); + const roadNmByField = (item.roadNm ?? "").trim(); + if (roadNmByField && Number.isInteger(mnoByField) && mnoByField > 0) { + return { roadNm: roadNmByField, bldgNoMno: mnoByField }; + } + + const firstSegment = (item.zipAddr ?? "").split(",")[0]?.trim() ?? ""; + if (!firstSegment) return null; + + const parts = firstSegment.split(/\s+/); + const bldgToken = parts[parts.length - 1] ?? ""; + const matched = bldgToken.match(/^(\d+)(?:-\d+)?$/); + if (!matched) return null; + + const bldgNoMno = Number(matched[1]); + const roadNm = parts.slice(0, -1).join(" ").trim(); + if (!roadNm || !Number.isInteger(bldgNoMno) || bldgNoMno <= 0) { + return null; + } + + return { roadNm, bldgNoMno }; +} + +/** + * 단일 입력 주소 문자열을 "도로명 + 건물번호" 형식으로 파싱해 API 조회 파라미터로 변환한다. + * 예) "테헤란로 152-1" -> { roadNm: "테헤란로", bldgNoMno: 152, bldgNoSno: 1, page } + * @param input 사용자가 입력한 주소 검색어 + * @param page 1부터 시작하는 화면 페이지 번호 + * @returns 파싱 성공 시 조회 파라미터와 정규화된 검색어, 실패 시 오류 메시지 + */ +export function parseZipSearchInput( + input: string, + page = 1, +): ParseZipSearchInputResult { + const normalized = input.replace(/\s+/g, " ").trim(); + + if (!normalized) { + return { + ok: false, + message: "도로명과 건물번호를 입력해 주세요. 예) 테헤란로 152-1", + }; + } + + if (!searchFilter(normalized)) { + return { + ok: false, + message: "검색어를 다시 확인해 주세요.", + }; + } + + const parts = normalized.split(" "); + if (parts.length < 2) { + if (/^\d+(?:-\d+)?$/.test(normalized)) { + return { ok: false, message: "도로명을 입력해 주세요." }; + } + if (/[가-힣]/.test(normalized) && !/\d/.test(normalized)) { + return { ok: false, message: "건물번호를 입력해 주세요." }; + } + return { + ok: false, + message: "도로명과 건물번호를 공백으로 구분해 주세요. 예) 테헤란로 152-1", + }; + } + + const bldgToken = parts[parts.length - 1] ?? ""; + const roadNm = parts.slice(0, -1).join(" ").trim(); + + if (!roadNm || !/[가-힣]/.test(roadNm)) { + return { + ok: false, + message: "도로명을 입력해 주세요.", + }; + } + + if (!/\d/.test(bldgToken)) { + return { ok: false, message: "건물번호를 입력해 주세요." }; + } + + if (!/^\d+(?:-\d+)?$/.test(bldgToken)) { + return { + ok: false, + message: "건물번호는 숫자 또는 숫자-숫자 형식으로 입력해 주세요. 예) 152 또는 152-1", + }; + } + + const [mnoStr, snoStr] = bldgToken.split("-"); + const bldgNoMno = Number(mnoStr); + const bldgNoSno = snoStr ? Number(snoStr) : null; + + if (!Number.isInteger(bldgNoMno) || bldgNoMno <= 0) { + return { ok: false, message: "건물본번은 1 이상의 숫자로 입력해 주세요." }; + } + + if (bldgNoSno !== null && (!Number.isInteger(bldgNoSno) || bldgNoSno <= 0)) { + return { ok: false, message: "건물부번은 1 이상의 숫자로 입력해 주세요." }; + } + + return { + ok: true, + value: { + roadNm, + bldgNoMno, + bldgNoSno, + page, + }, + keyword: `${roadNm} ${bldgNoMno}${bldgNoSno ? `-${bldgNoSno}` : ""}`, + }; +} + +/** + * 도로명 주소 문자열에서 재검색에 사용할 "도로명 건물번호" 입력값만 추출한다. + * 예) "테헤란로 152, OO빌딩" -> "테헤란로 152" + * @param roadAddr 화면에 표시된 도로명 주소 문자열 + * @returns 입력란에 다시 넣을 수 있는 문자열, 추출 실패 시 빈 문자열 + */ +export function extractSearchInputFromRoadAddr(roadAddr: string): string { + const firstSegment = roadAddr.split(",")[0]?.trim() ?? ""; + if (!firstSegment) return ""; + + const parts = firstSegment.split(/\s+/); + const bldgToken = parts[parts.length - 1] ?? ""; + if (!/^\d+(?:-\d+)?$/.test(bldgToken)) return ""; + + return `${parts.slice(0, -1).join(" ")} ${bldgToken}`.trim(); +} + +/** + * 사용자가 입력 중인 주소 문자열을 기준으로 도로명 후보를 조회한다. + * 건물번호가 함께 입력된 경우에는 마지막 숫자 토큰을 제외한 도로명만으로 후보를 찾는다. + * @param input 사용자가 입력창에 입력한 원본 문자열 + * @returns 최대 5개의 "도로명 건물본번" 자동완성 문자열 목록 + */ +export async function fetchPostcodeSuggestions(input: string): Promise { + const roadNm = extractRoadNameForSuggestion(input); + if (!roadNm) return []; + if (!searchFilter(roadNm)) return []; + + const data = await findAddress({ + roadNm, + page: 0, + size: 50, + }); + + const list = Array.isArray(data?.content) ? data.content : []; + const suggestions: string[] = []; + const seen = new Set(); + + for (const item of list) { + const seed = extractSuggestionSeed(item); + if (!seed) continue; + + const label = `${seed.roadNm} ${seed.bldgNoMno}`; + if (seen.has(label)) continue; + + seen.add(label); + suggestions.push(label); + if (suggestions.length >= 5) break; + } + + return suggestions; +}