diff --git a/packages/common/business_components/src/lib/utils.ts b/packages/common/business_components/src/lib/utils.ts index a5ef19350..1a87df14d 100644 --- a/packages/common/business_components/src/lib/utils.ts +++ b/packages/common/business_components/src/lib/utils.ts @@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); //clsx 결과 (단순 합치기):twMerge 결과 (Tailwind 충돌 제거) } diff --git a/packages/common/business_components/src/ui/index.ts b/packages/common/business_components/src/ui/index.ts index db1132b58..ae419bd83 100644 --- a/packages/common/business_components/src/ui/index.ts +++ b/packages/common/business_components/src/ui/index.ts @@ -15,6 +15,7 @@ export * from "./search_form_right"; export * from "./search_input"; export * from "./search_input_select"; // export * from "./search_input_address"; +export * from "./search_address"; export * from "./select"; export * from "./tab"; export * from "./table"; @@ -24,4 +25,4 @@ export * from "./sdmInsertForm"; export * from "./popover_ui"; export * from "./date_month_picker"; export * from "./date_picker_range" -export * from "./map/GisOverlay"; \ No newline at end of file +export * from "./map/GisOverlay"; diff --git a/packages/common/business_components/src/ui/sdmInsertForm/form.tsx b/packages/common/business_components/src/ui/sdmInsertForm/form.tsx index 6f0cbff2b..b2499ab43 100644 --- a/packages/common/business_components/src/ui/sdmInsertForm/form.tsx +++ b/packages/common/business_components/src/ui/sdmInsertForm/form.tsx @@ -1,4 +1,4 @@ -import { Button, Input, Label } from "@common/components"; +import { Button, Input } from "@common/components"; import { AsYouType } from "libphonenumber-js"; import React, { useCallback, useEffect, useState } from "react"; import type { ControllerRenderProps, UseFormReturn } from "react-hook-form"; @@ -6,10 +6,7 @@ import { FormField } from "../form"; import { FormUi } from "../form_ui"; import { SelectBox } from "../select"; import { SdmSearchForm } from "./sdmNameBox"; -import { UiModal, UiTable } from '../../ui'; -import type { Zip, ZipSearchParams } from "../search_input_address/zipService"; -import { findAddress } from "../search_input_address/findAddress"; -import { cn } from "@common/components"; +import { SearchAddressModal } from "../search_address/SearchAddressModal"; // 수요처 정보 export type DemandCompType = { @@ -91,7 +88,6 @@ export function SdmRegForm({ readOnly, openNetwork, }: SdmRegFormProps) { - const PAGE_SIZE = 10; const [sidoValue, setSidoValue] = useState(""); const [sigunValue, setSigunValue] = useState(""); const [filteredSigunData, setFilteredSigunData] = useState([ @@ -104,30 +100,6 @@ export function SdmRegForm({ const [zipId, setZipId] = useState(undefined); const [address, setAddress] = useState(""); const [detailedAddress, setDetailedAddress] = useState(""); - const [curPage, setCurPage] = useState(1); - // 우편번호 검색 관련 상태 - const [zipSearchResults, setZipSearchResults] = useState([]); - const [zipTotalElements, setZipTotalElements] = useState(-1); - const [searchQuery, setSearchQuery] = useState({ - roadNm: "", - bldgNoMno: null, - bldgNoSno: null, - page: curPage, - size: PAGE_SIZE - }); - const [isResetPage, setIsResetPage] = useState(false); - const [isResetSelection, setIsResetSelection] = useState(false); - const [selectedRowIndex, setSelectedRowIndex] = useState(undefined); - const [selectedZip, setSelectedZip] = useState(); - const [isSelectedZip, setIsSelectedZip] = useState(false); - - const handleCellClick = (index: any, headId: any) => { - console.log('index', index); - console.log('headId', headId); - setSelectedZip(zipSearchResults[index]); - setIsSelectedZip(true); - console.log('selectedZip', zipSearchResults[index]); - }; const formatPhoneNumber = (value: string) => { const input = value; @@ -293,17 +265,6 @@ export function SdmRegForm({ [orgBznmNo, setIsDuplicate], ); - // 우편번호 선택 함수 - const handleSelectAddress = () => { - setZipId(selectedZip?.zipId); - setAddress(selectedZip?.zipAddr || ""); - setDetailedAddress(""); - setIsZipModalOpen(false); - setSelectedZip(undefined); - setIsSelectedZip(false); - setSelectedRowIndex(undefined); - }; - useEffect(() => { if (updateList?.dmdInfoId) { form.reset(updateList); @@ -325,16 +286,6 @@ export function SdmRegForm({ console.log("form.formState.errors ", form.formState.errors); }, [form.formState.errors]); - useEffect(() => { - console.log("curPage ", curPage); - searchQuery.page = curPage - 1; - const fetchAddress = async () => { - const result = await findAddress(searchQuery); - setZipSearchResults(result?.content); - }; - fetchAddress(); - }, [curPage]); - // 폼 제출 전에 주소 합치기 useEffect(() => { // address와 detailedAddress를 합쳐서 daddr에 설정 @@ -344,105 +295,6 @@ export function SdmRegForm({ } }, [address, detailedAddress, form]); - const renderBody = () => ( - <> -
{ - e.preventDefault(); - e.stopPropagation(); - const result = await findAddress(searchQuery); - setZipSearchResults(result?.content); - setZipTotalElements(result?.totalElements); - }} - className="mb-4 space-y-2" - > - {/* 검색 조건 부분 */} -
-
-
- - - 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]" - /> -
-
- -
- -
-
-
- - {/* 검색 결과 부분 */} - {zipTotalElements > 0 && ( - - )} - {zipTotalElements === 0 && ( -
- - 조회된 데이터가 없습니다. -
- )} - - ); - const formFields: Array<{ name: keyof FormSchema; @@ -725,35 +577,14 @@ export function SdmRegForm({ /> ); })} - -1 ? 'h-[80vh]' : 'h-[30vh]'} - w-[30vw] - min-w-[500px] - max-w-[90vw] - max-h-[85vh] - overflow-y-auto - !fixed - !top-[50%] - !left-[50%] - !-translate-x-1/2 - !-translate-y-1/2 - z-[101] - flex - flex-col - `} - body={renderBody()} - footerContent={ -
- - -
- } - useDirectRender={true} + onSelect={(zip) => { + setZipId(zip.zipId); + setAddress(zip.zipAddr || ""); + setDetailedAddress(""); + }} /> ); diff --git a/packages/common/business_components/src/ui/search_address/SearchAddressModal.tsx b/packages/common/business_components/src/ui/search_address/SearchAddressModal.tsx new file mode 100644 index 000000000..55529f89a --- /dev/null +++ b/packages/common/business_components/src/ui/search_address/SearchAddressModal.tsx @@ -0,0 +1,221 @@ +import { Button, Input, Label, 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"; + +interface SearchAddressModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (zip: Zip) => void; +} + +export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddressModalProps) { + const PAGE_SIZE = 10; + const [curPage, setCurPage] = useState(1); + const [zipSearchResults, setZipSearchResults] = useState([]); + const [zipTotalElements, setZipTotalElements] = useState(-1); + const [searchQuery, setSearchQuery] = useState({ + roadNm: "", + bldgNoMno: null, + bldgNoSno: null, + page: 1, + size: PAGE_SIZE + }); + const [isResetPage, setIsResetPage] = useState(false); + const [isResetSelection, setIsResetSelection] = useState(false); + const [selectedRowIndex, setSelectedRowIndex] = useState(undefined); + const [selectedZip, setSelectedZip] = useState(); + const [isSelectedZip, setIsSelectedZip] = useState(false); + + // Initialize/Reset state when modal opens + useEffect(() => { + if (open) { + setCurPage(1); + setZipSearchResults([]); + setZipTotalElements(-1); + setSearchQuery({ + roadNm: "", + bldgNoMno: null, + bldgNoSno: null, + page: 0, + size: PAGE_SIZE + }); + setSelectedZip(undefined); + setIsSelectedZip(false); + setSelectedRowIndex(undefined); + setIsResetPage(true); + setTimeout(() => setIsResetPage(false), 100); + } + }, [open]); + + // Pagination Effect + useEffect(() => { + if (!open || zipTotalElements === -1) return; + + // Correctly update query for pagination without mutation + const nextPage = curPage - 1; + if (searchQuery.page !== nextPage) { + const newQuery = { ...searchQuery, page: nextPage }; + setSearchQuery(newQuery); + + const fetchAddress = async () => { + const result = await findAddress(newQuery); + setZipSearchResults(result?.content || []); + }; + fetchAddress(); + } + }, [curPage, open, zipTotalElements]); + + const handleCellClick = (index: any) => { + setSelectedZip(zipSearchResults[index]); + setIsSelectedZip(true); + setSelectedRowIndex(index); + }; + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const query = { ...searchQuery, page: 0 }; + setSearchQuery(query); + setCurPage(1); + setIsResetPage(true); + + const result = await findAddress(query); + setZipSearchResults(result?.content || []); + setZipTotalElements(result?.totalElements || 0); + + setTimeout(() => setIsResetPage(false), 100); + }; + + const handleConfirm = () => { + if (selectedZip) { + onSelect(selectedZip); + onOpenChange(false); + } + }; + + return ( + -1 ? 'h-[80vh]' : 'h-[30vh]'} + w-[30vw] + min-w-[500px] + max-w-[90vw] + min-h-[235px] + overflow-y-auto + !fixed + !top-[50%] + !left-[50%] + !-translate-x-1/2 + !-translate-y-1/2 + z-[101] + flex + flex-col + `} + body={ + <> +
+
+
+
+ + + 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]" + /> +
+
+ +
+ +
+
+
+ + {zipTotalElements > 0 && ( + + )} + {zipTotalElements === 0 && ( +
+ + 조회된 데이터가 없습니다. +
+ )} + + } + footerContent={ +
+ + +
+ } + useDirectRender={true} + /> + ); +} diff --git a/packages/common/business_components/src/ui/search_address/findAddress.ts b/packages/common/business_components/src/ui/search_address/findAddress.ts new file mode 100644 index 000000000..5e5ba4360 --- /dev/null +++ b/packages/common/business_components/src/ui/search_address/findAddress.ts @@ -0,0 +1,118 @@ + +// 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 new file mode 100644 index 000000000..b790bb492 --- /dev/null +++ b/packages/common/business_components/src/ui/search_address/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..03e4a8ccc --- /dev/null +++ b/packages/common/business_components/src/ui/search_address/zipService.ts @@ -0,0 +1,66 @@ +interface SortConfig { + empty: boolean; + sorted: boolean; + unsorted: boolean; +} + +interface PageableConfig { + sort: SortConfig; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; +} + +export interface PageableList { + content: T[]; + pageable: PageableConfig; + sort: SortConfig; + totalPages: number; + totalElements: number; + number: number; + numberOfElements: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface PageableParams { + page?: number; + size?: number; + sort?: string; +} + + +export interface ZipSearchParams extends PageableParams { + zoneNo?: string; // 구역번호 + ctpvNm?: string; // 시도명 + sggNm?: string; // 시군구명 + emdNm?: string; // 읍면동명 + roadNm?: string; // 도로명 + stdgNm?: string; // 법정동명 + dongNm?: string; // 행정동명 + bldgNoMno?: number | null; // 빌딩본번 + bldgNoSno?: number | null; // 빌딩딩부번 + lotnoMno?: number | null; // 지번본번 + lotnoSno?: number | null; // 지번부번 +} + +export interface Zip { + zipId: number; + zoneNo: string; + ctpvNm: string; + sggNm: string; + emdNm: string; + roadNm: string; + bldgNoMno: number; + bldgNoSno: number; + lotnoMno: number; + lotnoSno: number; + lotno: string; + zipAddr: string; +} + +export type ZipList = PageableList;