dm_new: 우편번호 검색 수정
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user