diff --git a/apps/dm_new/app/(with-layout)/apply/dmdco/page.tsx b/apps/dm_new/app/(with-layout)/apply/dmdco/page.tsx index ef219cf2a..9c403c4bd 100644 --- a/apps/dm_new/app/(with-layout)/apply/dmdco/page.tsx +++ b/apps/dm_new/app/(with-layout)/apply/dmdco/page.tsx @@ -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
; @@ -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; className?: string; }) { return ( (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(() => { 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} />
@@ -468,7 +521,7 @@ export default function DemandPlaceRegisterPage() {
setTel(v.replace(/\D/g, "").slice(0, 11))} + onChange={(v) => setTel(formatKoreanPhoneNumber(v))} placeholder="전화번호를 입력하세요." />
diff --git a/apps/dm_new/app/store/sdmMng/postcode-service.ts b/apps/dm_new/app/store/sdmMng/postcode-service.ts index b20716473..e8a791adb 100644 --- a/apps/dm_new/app/store/sdmMng/postcode-service.ts +++ b/apps/dm_new/app/store/sdmMng/postcode-service.ts @@ -43,6 +43,11 @@ export type PostcodeSearchResult = { items: AddrItem[]; }; +type SuggestionSeed = { + roadNm: string; + bldgNoMno: number; +}; + async function findAddress(searchQuery: ZipSearchParams): Promise { if (!searchQuery) return {}; @@ -63,6 +68,35 @@ async function findAddress(searchQuery: ZipSearchParams): Promise 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 { + 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(); + + 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 { diff --git a/apps/dm_new/components/PostcodeModal.tsx b/apps/dm_new/components/PostcodeModal.tsx index 9926557d9..116bf91d8 100644 --- a/apps/dm_new/components/PostcodeModal.tsx +++ b/apps/dm_new/components/PostcodeModal.tsx @@ -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([]); + const [suggestions, setSuggestions] = useState([]); 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) { - {/* suggestions dropdown -
- 이후 다른 API 연동 예정 -
- */} + {/* suggestions dropdown */} + {showSuggest && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )}