cm: 우편번호 떼어내기

This commit is contained in:
2026-01-16 17:37:49 +09:00
parent 3716e8c7b2
commit 943d858c5a
7 changed files with 419 additions and 179 deletions
@@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs)); //clsx 결과 (단순 합치기):twMerge 결과 (Tailwind 충돌 제거)
}
@@ -15,6 +15,7 @@ export * from "./search_form_right";
export * from "./search_input";
export * from "./search_input_select";
// export * from "./search_input_address";
export * from "./search_address";
export * from "./select";
export * from "./tab";
export * from "./table";
@@ -24,4 +25,4 @@ export * from "./sdmInsertForm";
export * from "./popover_ui";
export * from "./date_month_picker";
export * from "./date_picker_range"
export * from "./map/GisOverlay";
export * from "./map/GisOverlay";
@@ -1,4 +1,4 @@
import { Button, Input, Label } from "@common/components";
import { Button, Input } from "@common/components";
import { AsYouType } from "libphonenumber-js";
import React, { useCallback, useEffect, useState } from "react";
import type { ControllerRenderProps, UseFormReturn } from "react-hook-form";
@@ -6,10 +6,7 @@ import { FormField } from "../form";
import { FormUi } from "../form_ui";
import { SelectBox } from "../select";
import { SdmSearchForm } from "./sdmNameBox";
import { UiModal, UiTable } from '../../ui';
import type { Zip, ZipSearchParams } from "../search_input_address/zipService";
import { findAddress } from "../search_input_address/findAddress";
import { cn } from "@common/components";
import { SearchAddressModal } from "../search_address/SearchAddressModal";
// 수요처 정보
export type DemandCompType = {
@@ -91,7 +88,6 @@ export function SdmRegForm({
readOnly,
openNetwork,
}: SdmRegFormProps) {
const PAGE_SIZE = 10;
const [sidoValue, setSidoValue] = useState<string>("");
const [sigunValue, setSigunValue] = useState<string>("");
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
@@ -104,30 +100,6 @@ export function SdmRegForm({
const [zipId, setZipId] = useState<number | undefined>(undefined);
const [address, setAddress] = useState<string | undefined>("");
const [detailedAddress, setDetailedAddress] = useState<string>("");
const [curPage, setCurPage] = useState<number>(1);
// 우편번호 검색 관련 상태
const [zipSearchResults, setZipSearchResults] = useState<Zip[]>([]);
const [zipTotalElements, setZipTotalElements] = useState<number>(-1);
const [searchQuery, setSearchQuery] = useState<ZipSearchParams>({
roadNm: "",
bldgNoMno: null,
bldgNoSno: null,
page: curPage,
size: PAGE_SIZE
});
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
const [selectedRowIndex, setSelectedRowIndex] = useState<number | undefined>(undefined);
const [selectedZip, setSelectedZip] = useState<Zip>();
const [isSelectedZip, setIsSelectedZip] = useState<boolean>(false);
const handleCellClick = (index: any, headId: any) => {
console.log('index', index);
console.log('headId', headId);
setSelectedZip(zipSearchResults[index]);
setIsSelectedZip(true);
console.log('selectedZip', zipSearchResults[index]);
};
const formatPhoneNumber = (value: string) => {
const input = value;
@@ -293,17 +265,6 @@ export function SdmRegForm({
[orgBznmNo, setIsDuplicate],
);
// 우편번호 선택 함수
const handleSelectAddress = () => {
setZipId(selectedZip?.zipId);
setAddress(selectedZip?.zipAddr || "");
setDetailedAddress("");
setIsZipModalOpen(false);
setSelectedZip(undefined);
setIsSelectedZip(false);
setSelectedRowIndex(undefined);
};
useEffect(() => {
if (updateList?.dmdInfoId) {
form.reset(updateList);
@@ -325,16 +286,6 @@ export function SdmRegForm({
console.log("form.formState.errors ", form.formState.errors);
}, [form.formState.errors]);
useEffect(() => {
console.log("curPage ", curPage);
searchQuery.page = curPage - 1;
const fetchAddress = async () => {
const result = await findAddress(searchQuery);
setZipSearchResults(result?.content);
};
fetchAddress();
}, [curPage]);
// 폼 제출 전에 주소 합치기
useEffect(() => {
// address와 detailedAddress를 합쳐서 daddr에 설정
@@ -344,105 +295,6 @@ export function SdmRegForm({
}
}, [address, detailedAddress, form]);
const renderBody = () => (
<>
<form
onSubmit={async (e) => {
e.preventDefault();
e.stopPropagation();
const result = await findAddress(searchQuery);
setZipSearchResults(result?.content);
setZipTotalElements(result?.totalElements);
}}
className="mb-4 space-y-2"
>
{/* 검색 조건 부분 */}
<div className="card card-border !m-0 flex flex-col gap-4 p-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col">
<Label className="mb-1 font-bold text-base">도로명</Label>
<Input
value={searchQuery.roadNm || ""}
onChange={(e) =>
setSearchQuery({
...searchQuery,
roadNm: e.target.value,
})
}
placeholder="예: 테헤란로"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">건물본번</Label>
<Input
type="number"
value={searchQuery.bldgNoMno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
bldgNoMno: value,
});
}}
placeholder="예: 123"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">건물부번</Label>
<Input
type="number"
value={searchQuery.bldgNoSno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
bldgNoSno: value,
});
}}
placeholder="예: 45"
className="!text-[1.3rem]"
/>
</div>
</div>
<div className="flex justify-end">
<Button className="rounded-md" type="submit">검색</Button>
</div>
</div>
</form>
{/* 검색 결과 부분 */}
{zipTotalElements > 0 && (
<UiTable
headName=''
tableData={zipSearchResults}
headlist={[{ id: 'zoneNo', title: '우편번호' }, { id: 'zipAddr', title: '주소' }]}
pageSize={PAGE_SIZE}
total={zipTotalElements}
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
/>
)}
{zipTotalElements === 0 && (
<div className={cn("flex h-96 flex-col items-center justify-center gap-4")}>
<i className="diveicon di-info-circle text-3xl" />
조회된 데이터가 없습니다.
</div>
)}
</>
);
const formFields: Array<{
name: keyof FormSchema;
@@ -725,35 +577,14 @@ export function SdmRegForm({
/>
);
})}
<UiModal
headerTitle="주소 검색"
headerTitleClass="text-2xl font-bold text-gray-900"
<SearchAddressModal
open={isZipModalOpen}
onOpenChange={setIsZipModalOpen}
bodyClass={`
${zipTotalElements > -1 ? 'h-[80vh]' : 'h-[30vh]'}
w-[30vw]
min-w-[500px]
max-w-[90vw]
max-h-[85vh]
overflow-y-auto
!fixed
!top-[50%]
!left-[50%]
!-translate-x-1/2
!-translate-y-1/2
z-[101]
flex
flex-col
`}
body={renderBody()}
footerContent={
<div className="w-full flex justify-center items-center gap-4">
<Button className="rounded-md" size="lg" onClick={handleSelectAddress} disabled={!isSelectedZip}>선택</Button>
<Button className="rounded-md" size="lg" onClick={() => setIsZipModalOpen(false)}>취소</Button>
</div>
}
useDirectRender={true}
onSelect={(zip) => {
setZipId(zip.zipId);
setAddress(zip.zipAddr || "");
setDetailedAddress("");
}}
/>
</>
);
@@ -0,0 +1,221 @@
import { Button, Input, Label, cn } from "@common/components";
import React, { useState, useEffect } from "react";
import { UiModal } from "../dialog_ui";
import { UiTable } from "../table";
import type { Zip, ZipSearchParams } from "./zipService";
import { findAddress } from "./findAddress";
interface SearchAddressModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (zip: Zip) => void;
}
export function SearchAddressModal({ open, onOpenChange, onSelect }: SearchAddressModalProps) {
const PAGE_SIZE = 10;
const [curPage, setCurPage] = useState<number>(1);
const [zipSearchResults, setZipSearchResults] = useState<Zip[]>([]);
const [zipTotalElements, setZipTotalElements] = useState<number>(-1);
const [searchQuery, setSearchQuery] = useState<ZipSearchParams>({
roadNm: "",
bldgNoMno: null,
bldgNoSno: null,
page: 1,
size: PAGE_SIZE
});
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
const [selectedRowIndex, setSelectedRowIndex] = useState<number | undefined>(undefined);
const [selectedZip, setSelectedZip] = useState<Zip>();
const [isSelectedZip, setIsSelectedZip] = useState<boolean>(false);
// Initialize/Reset state when modal opens
useEffect(() => {
if (open) {
setCurPage(1);
setZipSearchResults([]);
setZipTotalElements(-1);
setSearchQuery({
roadNm: "",
bldgNoMno: null,
bldgNoSno: null,
page: 0,
size: PAGE_SIZE
});
setSelectedZip(undefined);
setIsSelectedZip(false);
setSelectedRowIndex(undefined);
setIsResetPage(true);
setTimeout(() => setIsResetPage(false), 100);
}
}, [open]);
// Pagination Effect
useEffect(() => {
if (!open || zipTotalElements === -1) return;
// Correctly update query for pagination without mutation
const nextPage = curPage - 1;
if (searchQuery.page !== nextPage) {
const newQuery = { ...searchQuery, page: nextPage };
setSearchQuery(newQuery);
const fetchAddress = async () => {
const result = await findAddress(newQuery);
setZipSearchResults(result?.content || []);
};
fetchAddress();
}
}, [curPage, open, zipTotalElements]);
const handleCellClick = (index: any) => {
setSelectedZip(zipSearchResults[index]);
setIsSelectedZip(true);
setSelectedRowIndex(index);
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();
const query = { ...searchQuery, page: 0 };
setSearchQuery(query);
setCurPage(1);
setIsResetPage(true);
const result = await findAddress(query);
setZipSearchResults(result?.content || []);
setZipTotalElements(result?.totalElements || 0);
setTimeout(() => setIsResetPage(false), 100);
};
const handleConfirm = () => {
if (selectedZip) {
onSelect(selectedZip);
onOpenChange(false);
}
};
return (
<UiModal
headerTitle="주소 검색"
headerTitleClass="text-2xl font-bold text-gray-900"
open={open}
onOpenChange={onOpenChange}
bodyClass={`
${zipTotalElements > -1 ? 'h-[80vh]' : 'h-[30vh]'}
w-[30vw]
min-w-[500px]
max-w-[90vw]
min-h-[235px]
overflow-y-auto
!fixed
!top-[50%]
!left-[50%]
!-translate-x-1/2
!-translate-y-1/2
z-[101]
flex
flex-col
`}
body={
<>
<form
onSubmit={handleSearch}
className="mb-4 space-y-2"
>
<div className="card card-border !m-0 flex flex-col gap-4 p-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col">
<Label className="mb-1 font-bold text-base">도로명</Label>
<Input
value={searchQuery.roadNm || ""}
onChange={(e) =>
setSearchQuery({
...searchQuery,
roadNm: e.target.value,
})
}
placeholder="예: 테헤란로"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">건물본번</Label>
<Input
type="number"
value={searchQuery.bldgNoMno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
bldgNoMno: value,
});
}}
placeholder="예: 123"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">건물부번</Label>
<Input
type="number"
value={searchQuery.bldgNoSno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
bldgNoSno: value,
});
}}
placeholder="예: 45"
className="!text-[1.3rem]"
/>
</div>
</div>
<div className="flex justify-end">
<Button className="rounded-md" type="submit">검색</Button>
</div>
</div>
</form>
{zipTotalElements > 0 && (
<UiTable
headName=''
tableData={zipSearchResults}
headlist={[{ id: 'zoneNo', title: '우편번호' }, { id: 'zipAddr', title: '주소' }]}
pageSize={PAGE_SIZE}
total={zipTotalElements}
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
/>
)}
{zipTotalElements === 0 && (
<div className={cn("flex h-96 flex-col items-center justify-center gap-4")}>
<i className="diveicon di-info-circle text-3xl" />
조회된 데이터가 없습니다.
</div>
)}
</>
}
footerContent={
<div className="w-full flex justify-center items-center gap-4">
<Button className="rounded-md" size="lg" onClick={handleConfirm} disabled={!isSelectedZip}>선택</Button>
<Button className="rounded-md" size="lg" onClick={() => onOpenChange(false)}>취소</Button>
</div>
}
useDirectRender={true}
/>
);
}
@@ -0,0 +1,118 @@
// const Key = 'U01TX0FVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA'
// const addresssKey = 'devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM='; //인증키 만료일은 2025-11-23
import type { Zip, ZipSearchParams } from "./zipService";
/**
* 주소 API 응답 타입
* https://business.juso.go.kr/addrlink/main.do 해당 사이트 참조
*/
export type AddressApiResponse = {
results: {
common: {
errorMessage: string;
countPerPage: string;
totalCount: string;
errorCode: string;
currentPage: string;
};
juso: addressType[];
};
}
export type addressType = {
detBdNmList: string;
engAddr: string;
rn: string;
emdNm: string;
zipNo: string;
roadAddrPart2: string;
emdNo: string;
sggNm: string;
jibunAddr: string;
siNm: string;
roadAddrPart1: string;
bdNm: string;
admCd: string;
udrtYn: string;
lnbrMnnm: string;
roadAddr: string;
lnbrSlno: string;
buldMnnm: string;
bdKdcd: string;
liNm: string;
rnMgtSn: string;
mtYn: string;
bdMgtSn: string;
buldSlno: string;
}
/**
* 주소에 따른 검색
* @param search : 검색어
* @returns addressType[] 주소 배열
*/
export const findAddress = async (searchQuery: ZipSearchParams): Promise<any> => {
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()}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
if (data) return data
else return [];
} catch (error) {
console.error('Error fetching address data:', error);
return [];
}
}
export const searchFilter = (search: string) => {
if (search.length > 0) {
//특수문자 제거
const expText = /[%=><]/;
if (expText.test(search) == true) {
alert("특수문자를 입력 할수 없습니다.");
search = search.split(expText).join("");
return false;
}
//특정문자열(sql예약어의 앞뒤공백포함) 제거
const sqlArray = [
//sql 예약어
"OR", "SELECT", "INSERT", "DELETE", "UPDATE", "CREATE", "DROP", "EXEC",
"UNION", "FETCH", "DECLARE", "TRUNCATE"
]
let regex;
for (let i = 0; i < sqlArray.length; i++) {
regex = new RegExp(sqlArray[i] as string, "gi");
if (regex.test(search)) {
alert("\"" + sqlArray[i] + "\"와(과) 같은 특정문자로 검색할 수 없습니다.");
search = search.replace(regex, "");
return false;
}
}
}
return true;
}
@@ -0,0 +1,3 @@
export * from "./SearchAddressModal";
export * from "./findAddress";
export * from "./zipService";
@@ -0,0 +1,66 @@
interface SortConfig {
empty: boolean;
sorted: boolean;
unsorted: boolean;
}
interface PageableConfig {
sort: SortConfig;
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
}
export interface PageableList<T> {
content: T[];
pageable: PageableConfig;
sort: SortConfig;
totalPages: number;
totalElements: number;
number: number;
numberOfElements: number;
size: number;
first: boolean;
last: boolean;
empty: boolean;
}
export interface PageableParams {
page?: number;
size?: number;
sort?: string;
}
export interface ZipSearchParams extends PageableParams {
zoneNo?: string; // 구역번호
ctpvNm?: string; // 시도명
sggNm?: string; // 시군구명
emdNm?: string; // 읍면동명
roadNm?: string; // 도로명
stdgNm?: string; // 법정동명
dongNm?: string; // 행정동명
bldgNoMno?: number | null; // 빌딩본번
bldgNoSno?: number | null; // 빌딩딩부번
lotnoMno?: number | null; // 지번본번
lotnoSno?: number | null; // 지번부번
}
export interface Zip {
zipId: number;
zoneNo: string;
ctpvNm: string;
sggNm: string;
emdNm: string;
roadNm: string;
bldgNoMno: number;
bldgNoSno: number;
lotnoMno: number;
lotnoSno: number;
lotno: string;
zipAddr: string;
}
export type ZipList = PageableList<Zip>;