dm_new: 우편번호 검색 수정

This commit is contained in:
dongsilove
2026-02-24 22:37:52 +09:00
parent 4cfe33bdc7
commit f812e021b4
3 changed files with 203 additions and 11 deletions
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import SubPageHeader from "@/components/SubPageHeader";
import PostcodeModal from "@/components/PostcodeModal";
import type { AddrItem } from "@/app/store/sdmMng/postcode-service";
@@ -29,6 +29,36 @@ const isSameRegionName = (left: string, right: string) => {
const b = normalizeRegionName(right);
return Boolean(a && b && (a === b || a.includes(b) || b.includes(a)));
};
const formatKoreanPhoneNumber = (value: string) => {
const digits = value.replace(/\D/g, "").slice(0, 11); // 숫자만 추출하고 최대 11자리까지만 사용
if (!digits) return ""; // 입력이 비어 있으면 빈 문자열 반환
if (/^1\d{0,7}$/.test(digits)) { // 15xx/16xx/18xx 같은 대표번호 패턴
if (digits.length <= 4) return digits; // 아직 뒷자리가 부족하면 원문 유지
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`; // xxxx-xxxx
}
if (digits.startsWith("02")) { // 서울 지역번호(02)
const rest = digits.slice(2); // 국번/가입자번호 구간
if (!rest) return "02"; // 02만 입력된 상태
if (rest.length <= 3) return `02-${rest}`; // 02-xxx
if (rest.length <= 7) return `02-${rest.slice(0, 3)}-${rest.slice(3)}`; // 02-xxx-xxxx
return `02-${rest.slice(0, 4)}-${rest.slice(4, 8)}`; // 02-xxxx-xxxx
}
if (digits.startsWith("0")) { // 02 제외 일반 지역번호/휴대폰(010 포함)
const area = digits.slice(0, 3); // 앞 3자리 식별자
const rest = digits.slice(3); // 나머지 번호
if (!rest) return area; // 식별자만 입력된 상태
if (rest.length <= 3) return `${area}-${rest}`; // 0xx-xxx
if (rest.length <= 7) return `${area}-${rest.slice(0, 3)}-${rest.slice(3)}`; // 0xx-xxx-xxxx
return `${area}-${rest.slice(0, 4)}-${rest.slice(4, 8)}`; // 0xx-xxxx-xxxx
}
if (digits.length <= 4) return digits; // 예외 입력: 앞 4자리까지
if (digits.length <= 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`; // xxxx-xxxx
return `${digits.slice(0, 4)}-${digits.slice(4, 8)}-${digits.slice(8, 11)}`; // xxxx-xxxx-xxx
};
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
@@ -63,6 +93,7 @@ function TextInput({
placeholder,
type = "text",
disabled,
inputRef,
className = "",
}: {
value: string;
@@ -70,10 +101,12 @@ function TextInput({
placeholder?: string;
type?: string;
disabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
className?: string;
}) {
return (
<input
ref={inputRef}
type={type}
value={value}
disabled={disabled}
@@ -138,6 +171,8 @@ export default function DemandPlaceRegisterPage() {
const [addr1, setAddr1] = useState("");
const [addr2, setAddr2] = useState("");
const [isPostcodeModalOpen, setIsPostcodeModalOpen] = useState(false);
const [focusDetailAddrOnClose, setFocusDetailAddrOnClose] = useState(false);
const detailAddrRef = useRef<HTMLInputElement | null>(null);
const [tel, setTel] = useState("");
const [pw, setPw] = useState("");
@@ -178,7 +213,7 @@ export default function DemandPlaceRegisterPage() {
setCompany(String(user.dmdCoNm ?? ""));
setAddr1(String(user.daddr ?? ""));
setAddr2("");
setTel(String(user.telno ?? "").replace(/\D/g, ""));
setTel(formatKoreanPhoneNumber(String(user.telno ?? "")));
setSido(String(user.ctpvCd ?? ""));
setSigungu(String(user.sggCd ?? ""));
} catch {
@@ -187,6 +222,22 @@ export default function DemandPlaceRegisterPage() {
}
}, []);
useEffect(() => {
if (!focusDetailAddrOnClose || isPostcodeModalOpen) return;
if (!addr1.trim()) {
setFocusDetailAddrOnClose(false);
return;
}
const timer = setTimeout(() => {
detailAddrRef.current?.focus();
setFocusDetailAddrOnClose(false);
}, 0);
return () => clearTimeout(timer);
}, [addr1, focusDetailAddrOnClose, isPostcodeModalOpen]);
const sidoOptions = useMemo<Option[]>(() => {
const mapped = selectSidoData
.filter((item) => item.val !== "0")
@@ -323,6 +374,7 @@ export default function DemandPlaceRegisterPage() {
const handleSelectAddress = (item: AddrItem) => {
setZip(item.zipNo);
setAddr1(item.roadAddr);
setFocusDetailAddrOnClose(true);
const matchedSido = selectSidoData.find(
(region) =>
region.val !== "0" &&
@@ -458,6 +510,7 @@ export default function DemandPlaceRegisterPage() {
value={addr2}
onChange={setAddr2}
placeholder="상세 주소를 입력하세요."
inputRef={detailAddrRef}
/>
</div>
</FormRow>
@@ -468,7 +521,7 @@ export default function DemandPlaceRegisterPage() {
<div className="max-w-[420px]">
<TextInput
value={tel}
onChange={(v) => setTel(v.replace(/\D/g, "").slice(0, 11))}
onChange={(v) => setTel(formatKoreanPhoneNumber(v))}
placeholder="전화번호를 입력하세요."
/>
</div>
@@ -43,6 +43,11 @@ export type PostcodeSearchResult = {
items: AddrItem[];
};
type SuggestionSeed = {
roadNm: string;
bldgNoMno: number;
};
async function findAddress(searchQuery: ZipSearchParams): Promise<ZipListResponse> {
if (!searchQuery) return {};
@@ -63,6 +68,35 @@ async function findAddress(searchQuery: ZipSearchParams): Promise<ZipListRespons
return (await response.json()) as ZipListResponse;
}
function extractSuggestionSeed(item: Zip): SuggestionSeed | null {
const ext = item as Zip & {
roadNm?: string;
bldgNoMno?: number | string;
};
const mnoByField = Number(ext.bldgNoMno);
const roadNmByField = (ext.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.at(-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 };
}
function searchFilter(search: string): boolean {
if (search.length === 0) return true;
@@ -113,6 +147,12 @@ export function parsePostcodeInput(
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",
@@ -122,13 +162,17 @@ export function parsePostcodeInput(
const bldgToken = parts.at(-1) ?? "";
const roadNm = parts.slice(0, -1).join(" ").trim();
if (!roadNm) {
if (!roadNm || !/[가-힣]/.test(roadNm)) {
return {
ok: false,
message: "도로명을 입력해 주세요. 예) 테헤란로 152-1",
message: "도로명을 입력해 주세요",
};
}
if (!/\d/.test(bldgToken)) {
return { ok: false, message: "건물번호를 입력해 주세요" };
}
if (!/^\d+(?:-\d+)?$/.test(bldgToken)) {
return {
ok: false,
@@ -172,6 +216,42 @@ export function extractSearchInputFromRoadAddr(roadAddr: string): string {
return `${parts.slice(0, -1).join(" ")} ${bldgToken}`.trim();
}
export async function fetchPostcodeSuggestions(input: string): Promise<string[]> {
const normalized = input.replace(/\s+/g, " ").trim();
const parts = normalized.split(" ");
const tail = parts.at(-1) ?? "";
const roadNm =
/^\d+(?:-\d+)?$/.test(tail) && parts.length > 1
? parts.slice(0, -1).join(" ").trim()
: normalized;
if (!roadNm) return [];
if (!searchFilter(roadNm)) return [];
const data = await findAddress({
roadNm,
page: 0,
size: 50,
});
const list = Array.isArray(data?.content) ? (data.content as Zip[]) : [];
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;
}
export async function fetchPostcode(
params: PostcodeSearchParams,
): Promise<PostcodeSearchResult> {
+65 -6
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
fetchPostcode,
fetchPostcodeSuggestions,
parsePostcodeInput,
type AddrItem,
type PostcodeSearchResult,
@@ -33,6 +34,7 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
const dq = useDebouncedValue(q, 250);
const [items, setItems] = useState<AddrItem[]>([]);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [searchMessage, setSearchMessage] = useState("");
@@ -41,6 +43,16 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
useEffect(() => setMounted(true), []);
useEffect(() => {
if (open) return;
setQ("");
setItems([]);
setSuggestions([]);
setTotal(0);
setLoading(false);
setSearchMessage("");
}, [open]);
useEffect(() => {
if (!open) return;
@@ -60,6 +72,32 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
};
}, [open, onClose]);
useEffect(() => {
if (!open) return;
const keyword = dq.trim();
if (!keyword) {
setSuggestions([]);
return;
}
let alive = true;
(async () => {
try {
const nextSuggestions = await fetchPostcodeSuggestions(keyword);
if (!alive) return;
setSuggestions(nextSuggestions);
} catch {
if (!alive) return;
setSuggestions([]);
}
})();
return () => {
alive = false;
};
}, [dq, open]);
useEffect(() => {
if (!open) return;
@@ -99,7 +137,10 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
setTotal(0);
setSearchMessage("우편번호 검색 중 오류가 발생했습니다.");
} finally {
if (alive) setLoading(false);
if (alive) {
setLoading(false);
setSuggestions([]);
}
}
})();
@@ -108,6 +149,8 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
};
}, [dq, open]);
const showSuggest = open && q.trim().length >= 1 && suggestions.length > 0;
if (!mounted || !open) return null;
return createPortal(
@@ -175,11 +218,27 @@ export default function PostcodeModal({ open, onClose, onSelect }: Props) {
</svg>
</button>
{/* suggestions dropdown
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-10 overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg">
이후 다른 API 연동 예정
</div>
*/}
{/* suggestions dropdown */}
{showSuggest && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-10 overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg">
{suggestions.map((s) => (
<button
key={s}
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-[14px] hover:bg-slate-50"
onClick={() => {
// ✅ 추천 클릭 → 입력값 채우고 드롭다운 닫기 + 포커스 유지
setQ(s);
setSuggestions([]);
requestAnimationFrame(() => inputRef.current?.focus());
}}
>
<span className="truncate text-slate-800">{s}</span>
<span className="ml-4 text-slate-400">↵</span>
</button>
))}
</div>
)}
</div>
</div>