From 7cb1a9ab6fe0253ea43a8555e0c0aeca701b46cf Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 24 Mar 2026 16:38:16 +0900 Subject: [PATCH] =?UTF-8?q?wr:=20=EB=8F=84=EB=A1=9C=EB=AA=85=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=8B=9C=20=EC=B6=94=EC=B2=9C=20=EB=82=98=ED=83=80?= =?UTF-8?q?=EB=82=98=EA=B3=A0=20=EA=B2=B0=EA=B3=BC=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=8B=9C=20=EC=8B=9C=EB=8F=84,=EC=8B=9C?= =?UTF-8?q?=EA=B5=B0=EA=B5=AC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ui/sdmInsertForm/form.tsx | 42 +++ .../ui/search_address/SearchAddressModal.tsx | 251 ++++++++++------ .../src/ui/search_address/findAddress.ts | 118 -------- .../src/ui/search_address/index.ts | 1 - .../src/ui/search_address/zipService.ts | 272 ++++++++++++++++++ 5 files changed, 477 insertions(+), 207 deletions(-) delete mode 100644 packages/common/business_components/src/ui/search_address/findAddress.ts diff --git a/packages/common/business_components/src/ui/sdmInsertForm/form.tsx b/packages/common/business_components/src/ui/sdmInsertForm/form.tsx index 72d3e1740..5ea6f435b 100644 --- a/packages/common/business_components/src/ui/sdmInsertForm/form.tsx +++ b/packages/common/business_components/src/ui/sdmInsertForm/form.tsx @@ -235,6 +235,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, @@ -584,6 +625,7 @@ export function SdmRegForm({ setZoneNo(zip.zoneNo || ""); setAddress(zip.zipAddr || ""); setDetailedAddress(""); + applyZipRegion(zip.ctpvNm, zip.sggNm); }} /> 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 20d74ce3c..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,12 +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; @@ -14,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 [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); @@ -36,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); @@ -53,43 +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; // 주소 검색 결과 건수 초기값이 -1일 때는 아직 검색 전 상태 + 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: number | undefined) => { if (index === undefined) return; - setSelectedZip(zipSearchResults[index]); + 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(); // 이벤트 버블링을 막음. 이벤트가 부모 컴포넌트나 바깥쪽 핸들러로 올라가지 않도록 막음. + 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); }; @@ -101,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="예) 테헤란로 152-1" - 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"}
@@ -201,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; +}