wr: 도로명 입력시 추천 나타나고 결과주소선택시 시도,시군구 설정
This commit is contained in:
@@ -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<HTMLInputElement>,
|
||||
@@ -584,6 +625,7 @@ export function SdmRegForm({
|
||||
setZoneNo(zip.zoneNo || "");
|
||||
setAddress(zip.zipAddr || "");
|
||||
setDetailedAddress("");
|
||||
applyZipRegion(zip.ctpvNm, zip.sggNm);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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<T>(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<number>(1);
|
||||
const [searchInput, setSearchInput] = useState<string>("");
|
||||
const debouncedSearchInput = useDebouncedValue(searchInput, 250);
|
||||
const [zipSearchResults, setZipSearchResults] = useState<Zip[]>([]);
|
||||
const [zipTotalElements, setZipTotalElements] = useState<number>(-1); //주소 검색 결과 건수
|
||||
const [searchQuery, setSearchQuery] = useState<ZipSearchParams>({
|
||||
roadNm: "",
|
||||
bldgNoMno: null,
|
||||
bldgNoSno: null,
|
||||
page: 1,
|
||||
size: PAGE_SIZE
|
||||
});
|
||||
const [searchMessage, setSearchMessage] = useState<string>("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState<boolean>(true);
|
||||
const [searchQuery, setSearchQuery] = useState<ZipSearchParams | undefined>(undefined);
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [isResetSelection] = useState<boolean>(false);
|
||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number | undefined>(undefined);
|
||||
const [selectedZip, setSelectedZip] = useState<Zip>();
|
||||
const [isSelectedZip, setIsSelectedZip] = useState<boolean>(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 (
|
||||
<UiModal
|
||||
headerTitle="주소 검색"
|
||||
@@ -129,64 +225,44 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
|
||||
onSubmit={handleSearch}
|
||||
className="mb-4 space-y-2"
|
||||
>
|
||||
<div className="card card-border !m-0 flex flex-col gap-4 p-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 font-bold text-base">도로명</Label>
|
||||
<Input
|
||||
value={searchQuery.roadNm || ""}
|
||||
onChange={(e) =>
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
roadNm: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예) 테헤란로 152-1"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 text-base">건물본번</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={searchQuery.bldgNoMno ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: null;
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
bldgNoMno: value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: 123"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 text-base">건물부번</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={searchQuery.bldgNoSno ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: null;
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
bldgNoSno: value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: 45"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
<div className="card card-border !m-0 flex flex-col gap-3 p-4">
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchInput(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
placeholder="예) 테헤란로 152-1"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
{showSuggest && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
setSearchInput(suggestion);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
}}
|
||||
>
|
||||
<span className="truncate text-gray-800">{suggestion}</span>
|
||||
<span className="ml-4 text-gray-400">↵</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button className="rounded-md shrink-0" type="submit">검색</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button className="rounded-md" type="submit">검색</Button>
|
||||
<div className="text-sm text-gray-600">
|
||||
{searchMessage || "도로명과 건물번호를 함께 입력해 주세요. 예) 테헤란로 152-1"}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -201,7 +277,6 @@ export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddre
|
||||
cellClick={handleCellClick}
|
||||
onPageChange={setCurPage}
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
index={selectedRowIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<any> => {
|
||||
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;
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./SearchAddressModal";
|
||||
export * from "./findAddress";
|
||||
export * from "./zipService";
|
||||
|
||||
@@ -64,3 +64,275 @@ export interface Zip {
|
||||
}
|
||||
|
||||
export type ZipList = PageableList<Zip>;
|
||||
|
||||
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<ZipList | undefined> {
|
||||
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<string[]> {
|
||||
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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user