diff --git a/.gitignore b/.gitignore index ab07be55d..80cd476eb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ apps/fo/.next/**/* pnpm-lock.yaml settings.json .npmrc -.cursor \ No newline at end of file +.cursor + +apps/**/tsconfig.tsbuildinfo \ No newline at end of file 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 c150ee1f7..ef219cf2a 100644 --- a/apps/dm_new/app/(with-layout)/apply/dmdco/page.tsx +++ b/apps/dm_new/app/(with-layout)/apply/dmdco/page.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import SubPageHeader from "@/components/SubPageHeader"; +import PostcodeModal from "@/components/PostcodeModal"; +import type { AddrItem } from "@/app/store/sdmMng/postcode-service"; import { useDmStore } from "@/app/store"; import { fetchType, getFetch, postFetch, putFetch } from "@/components/ui/api"; import { getCookie } from "@common/components/lib"; @@ -21,6 +23,12 @@ type SessionUser = { const TOKEN_ID_COOKIE = "token-id"; const FALLBACK_UUID = "c475824c-0e82-4a06-a282-2b55a20df265"; +const normalizeRegionName = (value: string) => value.replace(/\s+/g, "").trim(); +const isSameRegionName = (left: string, right: string) => { + const a = normalizeRegionName(left); + const b = normalizeRegionName(right); + return Boolean(a && b && (a === b || a.includes(b) || b.includes(a))); +}; function Divider() { return
; @@ -129,6 +137,7 @@ export default function DemandPlaceRegisterPage() { const [zip, setZip] = useState(""); const [addr1, setAddr1] = useState(""); const [addr2, setAddr2] = useState(""); + const [isPostcodeModalOpen, setIsPostcodeModalOpen] = useState(false); const [tel, setTel] = useState(""); const [pw, setPw] = useState(""); @@ -311,6 +320,29 @@ export default function DemandPlaceRegisterPage() { } }; + const handleSelectAddress = (item: AddrItem) => { + setZip(item.zipNo); + setAddr1(item.roadAddr); + const matchedSido = selectSidoData.find( + (region) => + region.val !== "0" && + isSameRegionName(region.text ?? "", item.ctpvNm ?? ""), + ); + + if (matchedSido) { + setSido(matchedSido.val); + const matchedSigungu = selectSigunData.find( + (region) => + region.val !== "0" && + region.val.startsWith(matchedSido.val) && + isSameRegionName(region.text ?? "", item.sggNm ?? ""), + ); + setSigungu(matchedSigungu?.val ?? ""); + } + + setIsPostcodeModalOpen(false); + }; + const pageTitle = editMode ? "수요처수정" : "수요처등록"; return ( @@ -414,7 +446,7 @@ export default function DemandPlaceRegisterPage() {
+ + setIsPostcodeModalOpen(false)} + onSelect={handleSelectAddress} + /> ); } diff --git a/apps/dm_new/app/store/sdmMng/postcode-service.ts b/apps/dm_new/app/store/sdmMng/postcode-service.ts new file mode 100644 index 000000000..b20716473 --- /dev/null +++ b/apps/dm_new/app/store/sdmMng/postcode-service.ts @@ -0,0 +1,210 @@ +export type AddrItem = { + zipNo: string; + roadAddr: string; + jibunAddr: string; + ctpvNm: string; + sggNm: string; +}; + +export interface ZipSearchParams { + roadNm?: string; + bldgNoMno?: number | null; + bldgNoSno?: number | null; + page?: number; + size?: number; +} + +export interface Zip { + zoneNo: string; + ctpvNm: string; + sggNm: string; + lotno: string; + zipAddr: string; +} + +interface ZipListResponse { + content?: Zip[]; + totalElements?: number; +} + +export type PostcodeSearchParams = { + roadNm: string; + bldgNoMno: number; + bldgNoSno: number | null; + page: number; +}; + +export type ParsePostcodeInputResult = + | { ok: true; value: PostcodeSearchParams; keyword: string } + | { ok: false; message: string }; + +export type PostcodeSearchResult = { + totalCount: number; + items: AddrItem[]; +}; + +async function findAddress(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()}`; + const response = await fetch(url); + if (!response.ok) throw new Error("Network response was not ok"); + return (await response.json()) as ZipListResponse; +} + +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; +} + +export function parsePostcodeInput( + input: string, + page = 1, +): ParsePostcodeInputResult { + const normalized = input.replace(/\s+/g, " ").trim(); + + if (!normalized) { + return { + ok: false, + message: "도로명과 건물번호를 입력해 주세요. 예) 테헤란로 152-1", + }; + } + + const parts = normalized.split(" "); + if (parts.length < 2) { + return { + ok: false, + message: "도로명과 건물번호를 공백으로 구분해 주세요. 예) 테헤란로 152-1", + }; + } + + const bldgToken = parts.at(-1) ?? ""; + const roadNm = parts.slice(0, -1).join(" ").trim(); + + if (!roadNm) { + return { + ok: false, + message: "도로명을 입력해 주세요. 예) 테헤란로 152-1", + }; + } + + 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}` : ""}`, + }; +} + +export function extractSearchInputFromRoadAddr(roadAddr: string): string { + const firstSegment = roadAddr.split(",")[0]?.trim() ?? ""; + if (!firstSegment) return ""; + + const parts = firstSegment.split(/\s+/); + const bldgToken = parts.at(-1) ?? ""; + if (!/^\d+(?:-\d+)?$/.test(bldgToken)) return ""; + + return `${parts.slice(0, -1).join(" ")} ${bldgToken}`.trim(); +} + +export async function fetchPostcode( + params: PostcodeSearchParams, +): Promise { + const keywordForCheck = `${params.roadNm} ${params.bldgNoMno}${ + params.bldgNoSno ? `-${params.bldgNoSno}` : "" + }`; + if (!searchFilter(keywordForCheck)) { + return { + totalCount: 0, + items: [], + }; + } + + const query: ZipSearchParams = { + roadNm: params.roadNm, + bldgNoMno: params.bldgNoMno, + bldgNoSno: params.bldgNoSno, + page: Math.max(params.page - 1, 0), + size: 20, + }; + + const data = await findAddress(query); + const list = Array.isArray(data?.content) ? (data.content as Zip[]) : []; + const items: AddrItem[] = list.map((item) => ({ + zipNo: item.zoneNo ?? "", + roadAddr: item.zipAddr ?? "", + jibunAddr: item.lotno ?? item.zipAddr ?? "", + ctpvNm: item.ctpvNm ?? "", + sggNm: item.sggNm ?? "", + })); + + return { + totalCount: Number(data?.totalElements ?? 0), + items, + }; +} diff --git a/apps/dm_new/components/PostcodeModal.tsx b/apps/dm_new/components/PostcodeModal.tsx new file mode 100644 index 000000000..9926557d9 --- /dev/null +++ b/apps/dm_new/components/PostcodeModal.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + fetchPostcode, + parsePostcodeInput, + type AddrItem, + type PostcodeSearchResult, +} from "@/app/store/sdmMng/postcode-service"; + +export type { AddrItem } from "@/app/store/sdmMng/postcode-service"; + +type Props = { + open: boolean; + onClose: () => void; + onSelect: (item: AddrItem) => void; +}; + +function useDebouncedValue(value: T, delay = 250) { + const [v, setV] = useState(value); + useEffect(() => { + const t = setTimeout(() => setV(value), delay); + return () => clearTimeout(t); + }, [value, delay]); + return v; +} + +export default function PostcodeModal({ open, onClose, onSelect }: Props) { + const [mounted, setMounted] = useState(false); + + const [q, setQ] = useState(""); + const dq = useDebouncedValue(q, 250); + + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [searchMessage, setSearchMessage] = useState(""); + + const inputRef = useRef(null); + + useEffect(() => setMounted(true), []); + + useEffect(() => { + if (!open) return; + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + setTimeout(() => inputRef.current?.focus(), 50); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + + return () => { + document.body.style.overflow = prevOverflow; + window.removeEventListener("keydown", onKeyDown); + }; + }, [open, onClose]); + + useEffect(() => { + if (!open) return; + + const keyword = dq.trim(); + + if (!keyword) { + setItems([]); + setTotal(0); + setLoading(false); + setSearchMessage(""); + return; + } + + const parsed = parsePostcodeInput(keyword, 1); + if (!parsed.ok) { + setItems([]); + setTotal(0); + setLoading(false); + setSearchMessage(parsed.message); + return; + } + + let alive = true; + + (async () => { + setLoading(true); + setSearchMessage(""); + try { + const result: PostcodeSearchResult = await fetchPostcode(parsed.value); + if (!alive) return; + + setItems(result.items); + setTotal(result.totalCount); + } catch { + if (!alive) return; + setItems([]); + setTotal(0); + setSearchMessage("우편번호 검색 중 오류가 발생했습니다."); + } finally { + if (alive) setLoading(false); + } + })(); + + return () => { + alive = false; + }; + }, [dq, open]); + + if (!mounted || !open) return null; + + return createPortal( +
+ +
+ +
+
+ setQ(e.target.value)} + placeholder="예) 테헤란로 152-1" + className={[ + "w-full rounded-md border px-4 py-3 pr-12 text-[14px] outline-none", + "border-slate-300 focus:border-sky-500 focus:ring-2 focus:ring-sky-200", + ].join(" ")} + /> + + + + {/* suggestions dropdown +
+ 이후 다른 API 연동 예정 +
+ */} +
+
+ +
+
+ {q.trim() + ? loading + ? "검색 중..." + : searchMessage || `총 ${total.toLocaleString()}개의 검색결과` + : "도로명과 건물번호를 입력하세요. 예) 테헤란로 152-1"} +
+ +
+ {!loading && q.trim() && items.length === 0 && !searchMessage && ( +
+ 검색 결과가 없습니다. +
+ )} + +
+ {items.map((it) => ( + + ))} +
+
+
+ + + , + document.body, + ); +}