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,
+ );
+}