Merge branch 'iipbs/sn/dev'

This commit is contained in:
2026-01-21 10:35:28 +09:00
25 changed files with 609 additions and 317 deletions
+6 -8
View File
@@ -85,22 +85,20 @@ export const DmStatusReducer = (set: any): DmStatusActions => {
const boardData: DmStatusBoardType[] = data.data.map(
(item: DmStatusDataType) => {
const regionText = cityCd.find((city) => item.region === city.val);
const regionText = item.sidoNm; //cityCd.find((city) => item.region === city.val);
const sewage = item.sewage;
const rainwater = item.rainwater;
const reclaimed = item.reclaimed;
const sewageCnt = item.sewageCnt;
const rainwaterCnt = item.rainwaterCnt;
const reclaimedCnt = item.reclaimedCnt;
if (!regionText) console.log("item.region ", item.region);
if (regionText && regionText.text) {
return {
region: regionText ? regionText.text : item.region,
sewage: sewageCnt + "(" + sewage + ")",
rainwater: rainwaterCnt + "(" + rainwater + ")",
reclaimed: reclaimedCnt + "(" + reclaimed + ")",
region: regionText ? regionText : item.region,
sewage: sewageCnt + "(" + sewage.toLocaleString() + ")",
rainwater: rainwaterCnt + "(" + rainwater.toLocaleString() + ")",
reclaimed: reclaimedCnt + "(" + reclaimed.toLocaleString() + ")",
};
}
}
).filter((item): item is DmStatusBoardType => item !== undefined);
+6 -4
View File
@@ -1,14 +1,16 @@
export type DmStatusDataType = {
region: string;
sidoNm: string; // 시도명
region: string; // 시도코드
sewage: string;
rainwater: string;
reclaimed: string;
sewageCnt:string
rainwaterCnt:string
reclaimedCnt:string
sewageCnt:string;
rainwaterCnt:string;
reclaimedCnt:string;
};
export type DmStatusBoardType = {
sidoNm: string; // 시도명
region: string;
sewage: string;
rainwater: string;
@@ -209,10 +209,13 @@ export default function FacilityPrecipitation() {
<ContentsDiv>
<SearchBaseForm
leftChildren={<>
<SelectBox className="col-span-3" label={labelArray[3] as string} selectArray={selectUpdownData} onSelectValue={(val) => setFcltCpct(val.toString())} value={fcltCpct} selectClass={undefined} />
<SelectBox className="col-span-3" label={labelArray[3] as string} selectArray={selectUpdownData}
onSelectValue={(val) => setFcltCpct(val.toString())} value={fcltCpct} selectClass={undefined} />
<SearchInput className={"col-span-3"} label={labelArray[4] as string} textValue={fcltyName} setText={setFcltyName} />
<SelectBox className="col-span-3" label={labelArray[6] as string} selectArray={selectSearchYear} onSelectValue={(val) => setSearchYear(val.toString())} value={searchYear} selectClass={undefined} />
<SelectBox className="col-span-3" label={labelArray[5] as string} selectArray={selectOperationData} onSelectValue={(val) => setOperationValue(val.toString())} value={operationValue} selectClass={undefined} />
<SelectBox className="col-span-3" label={labelArray[6] as string} selectArray={selectSearchYear}
onSelectValue={(val) => setSearchYear(val.toString())} value={searchYear} selectClass={undefined} />
<SelectBox className="col-span-3" label={labelArray[5] as string} selectArray={selectOperationData}
onSelectValue={(val) => setOperationValue(val.toString())} value={operationValue} selectClass={undefined} />
</>}
rightChildren={undefined}
selectSidoData={selectSidoData}
@@ -226,16 +229,19 @@ export default function FacilityPrecipitation() {
excelClick={excelDownload}
initClick={resetVal}
ref={ref} isAdmin={false}/>
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)} headName={"시설별 강수현황"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
<ListDiv>
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)}
headName={"시설별 강수현황"}
tableData={mainList} headlist={upheadList} pageSize={10} total={total}
resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
<UnderComment divHeight={"3"} commentArray={["강우일은 3mm 이상만 해당 / 강우영향일은 강우일 및 강우일 후 연속 2일간으로 정의"]} />
</div>
<UnderComment divHeight={"3"} commentArray={["강우일은 3mm 이상만 해당 / 강우영향일은 강우일 및 강우일 후 연속 2일간으로 정의"]} />
</div>
</ListDiv>
</ContentsDiv>
</ListDiv>
</ContentsDiv>
{selectedRow !== null && (
<UiModal
body={<PrecipitationModal searchYear={searchYear} fcltyName={fcltyNm} facilityCd={fcltyCd} detailList={gridListPrecipitationDetail}></PrecipitationModal>}
@@ -77,7 +77,7 @@ export default function WaterQualityModal({ fcltyName = '', facilityCd = '', sea
useEffect(() => {
const filteredYearData = setFilterAfterData(facilityWaterQltyYearList);
setCopyYearList(filteredYearData as FacilityWaterQltyProps[]);
setChongInWaterQltyYearList(filteredYearData as FacilityWaterQltyProps[]);
setChongInWaterQltyYearList(facilityWaterQltyYearList);
}, [facilityWaterQltyYearList]);
// 필터링 된 후 최대, 최소, 평균 값 계산
@@ -344,8 +344,7 @@ export default function WaterQualityModal({ fcltyName = '', facilityCd = '', sea
const filterData = useCallback((returnType: ReturnType<FacilityWaterQltyProps>) => {
const filteredData = setFilterAfterData(returnType.filteredList);
setCopyYearList(filteredData as FacilityWaterQltyProps[]); // 처리시설 수질현황
setChongInWaterQltyYearList(filteredData as FacilityWaterQltyProps[]); // 총인설비 수질현황
setCopyYearList(filteredData as FacilityWaterQltyProps[]); // 처리시설/총인설비 수질현황
}, []);
const excelDownload = () => {
@@ -386,10 +385,10 @@ export default function WaterQualityModal({ fcltyName = '', facilityCd = '', sea
prevExcelListRef.current = excelDownList;
} else { // 총인설비 수질현황
const excelDownList: modalChoinginExcelType[] = [];
if (chongInWaterQltyYearList.length > 0) {
chongInWaterQltyYearList.forEach((item) => {
// if (copyYearList.length > 0) {
// copyYearList.forEach((item) => {
// if (chongInWaterQltyYearList.length > 0) {
// chongInWaterQltyYearList.forEach((item) => {
if (copyYearList.length > 0) {
copyYearList.forEach((item) => {
excelDownList.push({
날짜: item.msrDt,
"유입BOD(mg/L)": CommaToNumber(item.tpInflWqltBod),
@@ -610,4 +609,4 @@ export default function WaterQualityModal({ fcltyName = '', facilityCd = '', sea
</UITab>
</>
);
}
}
+2 -1
View File
@@ -215,7 +215,8 @@ export default function Rdi() {
<LoadingOverlay isLoading={isLoading} />
<div className="h-full w-full overflow-x-scroll min-[1200px]:overflow-x-hidden">
<div className="w-[120%] min-[1200px]:w-[100%]">
<UiTable cellClick={ undefined } headName={"RDII추정"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
<UiTable cellClick={ undefined } headName={"RDII추정"} tableData={mainList} headlist={upheadList}
pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
</div>
<UnderComment divHeight={"0"} commentArray={commentArray} />
</div>
@@ -153,7 +153,9 @@ export const fetchFacilityRainDetailList = async (searchYear: string|undefined,
}
};
const fetchFacilityPrecipitationList = async (pageNo: number, pageSize: number, searchYear: string, part?: string, nameCd?: string, fcltCpct?: string, operationValue?: string, fcltyNm?: string) => {
const fetchFacilityPrecipitationList = async (pageNo: number, pageSize: number, searchYear: string, part?: string
, nameCd?: string, fcltCpct?: string, operationValue?: string, fcltyNm?: string) => {
const tailStr = '/'+pageNo + '/' + pageSize + '/' + searchYear + (part?'/' + part:'' ) + (nameCd?'/' + nameCd:'' ) + (fcltCpct?'/' + fcltCpct:'' ) + (operationValue?'/' + operationValue:'' ) + (fcltyNm && fcltyNm !== '' ? '/' + fcltyNm : '') ;
const url = '/fclt/rain' + tailStr;
const res = await getFetch('fo', url);
@@ -24,6 +24,7 @@ import { Button, Label } from "@common/components/ui";
import { useEffect, useState } from "react";
import toast, { Toaster } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import * as XLSX from "xlsx";
export default function RuseCaseMng() {
const { code, ruseCaseMng, selectRuseCaseMng, ruseCaseMngInit } = useWrStore(
@@ -76,7 +77,9 @@ export default function RuseCaseMng() {
};
useEffect(() => {
if (excelList !== undefined && excelList.length === 0) {
if (!excelList) return;
if (excelList.length === 0) {
toast.error(t("excelDownErrMsg"));
return;
}
@@ -86,74 +89,93 @@ export default function RuseCaseMng() {
}
// 받은 파일 바인너리 처리
const blobRet = await response.arrayBuffer();
if (excelList) {
const excelData1 = excelList.map((item) => {
return [
item.ruseBizNm,
item.bizBgngDate,
item.bizEndDate,
item.splyFcltKnd,
item.splyFcltNm,
item.splyFcltCpct,
item.dmdFcltNm,
item.dmdFcltAddr,
item.ruseFcltCpct,
item.ruseFcltPplLen,
item.ruseFcltUsg,
item.ruseFcltSplyCpct,
item.ruseFcltPrcsMsrc,
item.grnteWqUsg,
item.grnteWqGrssClfg,
item.grnteWqCombResdch,
item.grnteWqTrbt,
item.grnteWqBod,
item.grnteWqSmll,
item.grnteWqCrmty,
item.grnteWqTn,
item.grnteWqTp,
item.grnteWqPh,
item.grnteWqClIonQnt,
item.grnteWqCndctStat,
item.grnteWqRmrk,
item.reqBizGrssCst,
item.reqBizReuseFcltCst,
item.reqBizPplCst,
item.reqBizArchCst,
item.reqBizMchnCst,
item.reqBizMtrngCst,
item.reqBizGisCst,
item.reqBizCctvCst,
item.reqBizCmcnCst,
item.reqBizWstCst,
item.reqBizDesignCst,
item.reqBizSpvsCst,
item.reqBizSbsdCst,
item.reqBizEtc,
item.prcmPlanGrssCst,
item.prcmPlanNlep,
item.prcmPlanLcep,
item.prcmPlanBrdnCst,
item.prcmPlanEcqrRt,
item.clclnGrssCst,
item.clclnNlep,
item.clclnLcep,
item.clclnBrdnCst,
item.clclnEcqrRt,
];
});
const sheetParameter: SheetParameter[] = [
{
data: excelData1,
headerNum: 2,
headerRange: "A1:AX1",
sheetName: "물재이용사례",
startCol: "A",
},
const replaceHeaderText = (text: string) =>
text
.replaceAll("사업시작일", "사업시작년")
.replaceAll("사업종료일", "사업종료년");
const wb = XLSX.read(blobRet, { type: "array" });
wb.SheetNames.forEach((sheetName) => {
const sheet = wb.Sheets[sheetName];
if (!sheet) return;
Object.keys(sheet).forEach((addr) => {
if (addr.startsWith("!")) return;
const cell = sheet[addr] as XLSX.CellObject | undefined;
if (!cell || typeof cell.v !== "string") return;
const next = replaceHeaderText(cell.v);
if (next !== cell.v) cell.v = next;
});
});
const updatedBlob = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const excelData1 = excelList.map((item) => {
return [
item.ruseBizNm,
item.bizBgngDate,
item.bizEndDate,
item.splyFcltKnd,
item.splyFcltNm,
item.splyFcltCpct,
item.dmdFcltNm,
item.dmdFcltAddr,
item.ruseFcltCpct,
item.ruseFcltPplLen,
item.ruseFcltUsg,
item.ruseFcltSplyCpct,
item.ruseFcltPrcsMsrc,
item.grnteWqUsg,
item.grnteWqGrssClfg,
item.grnteWqCombResdch,
item.grnteWqTrbt,
item.grnteWqBod,
item.grnteWqSmll,
item.grnteWqCrmty,
item.grnteWqTn,
item.grnteWqTp,
item.grnteWqPh,
item.grnteWqClIonQnt,
item.grnteWqCndctStat,
item.grnteWqRmrk,
item.reqBizGrssCst,
item.reqBizReuseFcltCst,
item.reqBizPplCst,
item.reqBizArchCst,
item.reqBizMchnCst,
item.reqBizMtrngCst,
item.reqBizGisCst,
item.reqBizCctvCst,
item.reqBizCmcnCst,
item.reqBizWstCst,
item.reqBizDesignCst,
item.reqBizSpvsCst,
item.reqBizSbsdCst,
item.reqBizEtc,
item.prcmPlanGrssCst,
item.prcmPlanNlep,
item.prcmPlanLcep,
item.prcmPlanBrdnCst,
item.prcmPlanEcqrRt,
item.clclnGrssCst,
item.clclnNlep,
item.clclnLcep,
item.clclnBrdnCst,
item.clclnEcqrRt,
];
// 버퍼에 있는 엑셀데이터 전달 후 json으로 변환
await filePickedBlobArray(blobRet, "물재이용사례현황", sheetParameter);
}
});
const sheetParameter: SheetParameter[] = [
{
data: excelData1,
headerNum: 2,
headerRange: "A1:AX1",
sheetName: "물재이용사례",
startCol: "A",
},
];
// 버퍼에 있는 엑셀데이터 전달 후 json으로 변환
await filePickedBlobArray(updatedBlob, "물재이용사례현황", sheetParameter);
});
}, [excelList]);
@@ -299,8 +321,12 @@ export default function RuseCaseMng() {
}}
resetPageNo={resetPage}
/>
<span className="text-2xl font-bold text-gray-500">
☞ 본 사례의 사업비는 재원협의 단계의 정보이므로, 최종확정된 비용이 아닙니다.
</span>
</div>
</ListDiv>
</div>
</div>
</ContentsDiv>
@@ -1,3 +1,19 @@
/**
* 재이용시설 등록 - 하수재이용 시설등록
*
* - layout.tsx에 의해 공통 레이아웃이 적용됨
* - with-layout 적용
* - 하수처리시설에 하수재이용 시설을 등록 한다.
* - form으로 시설정보 및 차수별 정보의 컴포넌트 데이터를 관리한다
* indexMng: 시설정보
* indexBiz: 차수별 정보
* 로 하수재이용 시설에 등록된 시설 및 차수별 정보를 관리한다.
* 🔗 사용 API
* 하수재이용시설을 등록 /reuseFc/pubFc
* 첨부파일 삭제 /attach-service/api/v1/atfl/attachments/atflCd//
* 첨부파일 다운로드 /attach-service/api/v1/atflcd/download/
* */
"use client";
import {
ContentsDiv,
@@ -35,18 +51,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createDefaultPbnstItem } from "./utilsZod";
import { useTranslation } from "react-i18next";
/**
* 재이용시설 등록 - 하수재이용 시설등록
*
* - layout.tsx에 의해 공통 레이아웃이 적용됨
* - with-layout 적용
* - 하수처리시설에 하수재이용 시설을 등록 한다.
* - form으로 시설정보 및 차수별 정보의 컴포넌트 데이터를 관리한다
* indexMng: 시설정보
* indexBiz: 차수별 정보
* 로 하수재이용 시설에 등록된 시설 및 차수별 정보를 관리한다.
* */
export default function PublicReuseFaciltyInsert() {
const {
auth,
@@ -104,7 +108,7 @@ export default function PublicReuseFaciltyInsert() {
useEffect(() => {
if (useInitRef.current == true && laeSeCd !== undefined) {
pubHasuInit("1", laeSeCd);
pubHasuInit("1", laeSeCd); //page: string, sggOrCity: string
}
}, [useInitRef.current, laeSeCd]);
@@ -1,5 +1,7 @@
/**
* 온배수시설 현황
*/
"use client";
import { useWrStore } from "@/app/store";
import { btnClass } from "@/app/store/common/code";
import type {
@@ -34,11 +36,9 @@ import toast, { Toaster } from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function HtdflSttn() {
const { code, htdflSttn, htdflSttnSearch, initHtdfl } = useWrStore(
(state) => state
);
const { auth, code, htdflSttn, htdflSttnSearch, initHtdfl } = useWrStore((state) => state);
const { laeSeCd } = auth; // 지자체구분코드
const { htdflSttnRes, htdflSttnResCount } = htdflSttn;
const isDownloadTriggered = useRef(false);
const { t } = useTranslation('base');
const [pageNation, setPageNation] = useState(1);
@@ -57,6 +57,7 @@ export default function HtdflSttn() {
// }>({ isOpen: false, chBuilding: [] });
const [fcltNm, setFcltNm] = useState<string>("");
console.log("laeSeCd : ", laeSeCd);
useEffect(() => {
htdflSttnSearch(false, pageNation, htdflSttnSize, String(getConValue()), fcltNm, code.cityCd, code.sggCd);
isResetPage(false)
@@ -1,3 +1,12 @@
/**
* 하수재이용 시설 현황
* 재이용시설 현황 - 하수재이용시설 현황
* - layout.tsx에 의해 공통 레이아웃이 적용됨
* - with-layout 적용
* 하수처리시설. 하수재이용시설 , 하수재이용시설 실적에 대한 정보를 확인한다.
* 모달 : 테이블의 헤더를 원하는대로 조절해서 볼수 있다.
* 클릭시 해당 재이용ㅇ
* */
"use client";
import { useWrStore } from "@/app/store";
import { btnClass } from "@/app/store/common/code";
@@ -24,15 +33,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import toast, { Toaster } from "react-hot-toast";
import { useTranslation } from "react-i18next";
/**
* 하수재이용 시설 현황
* 재이용시설 현황 - 하수재이용시설 현황
* - layout.tsx에 의해 공통 레이아웃이 적용됨
* - with-layout 적용
* 하수처리시설. 하수재이용시설 , 하수재이용시설 실적에 대한 정보를 확인한다.
* 모달 : 테이블의 헤더를 원하는대로 조절해서 볼수 있다.
* 클릭시 해당 재이용ㅇ
* */
export default function PbnstSttn() {
const {
code,
@@ -1,3 +1,6 @@
/**
* 빗물재이용시설 현황
*/
"use client";
import { useWrStore } from "@/app/store";
import { btnClass } from "@/app/store/common/code";
@@ -1,3 +1,6 @@
/**
* 중수도시설 현황
*/
"use client";
import { useWrStore } from "@/app/store";
import { btnClass } from "@/app/store/common/code";
+2 -1
View File
@@ -10,7 +10,7 @@ export type authState = {
regAuthrtYn: string;
mdfcnAuthrtYn: string;
delAuthrtYn: string;
laeSeCd: string;
laeSeCd: string; // 지자체구분코드
myUUID?: string;
};
};
@@ -72,6 +72,7 @@ export const authActionsExport: (set: any, get: any) => authActions = (
return;
}
const infoMe = await getUserInfo();
console.log(infoMe);
const isLaeSeCd =
infoMe.laeSeCd.slice(0, 2) === "G3" ? infoMe.laeSeCd.slice(2) : "0";
@@ -11,14 +11,14 @@ export const ruseCaseHeadListExport: TableUpperProps[] = [
},
{
id: "bizBgngDate",
title: "사업시작일",
title: "사업시작년",
visible: true,
row: 0,
rowSpan: 2,
},
{
id: "bizEndDate",
title: "사업종료일",
title: "사업종료년",
visible: true,
row: 0,
rowSpan: 2,
@@ -118,7 +118,7 @@ const hasuListFetch = async (
if (searchTxt) {
url = url + `/${searchTxt}`;
}
const rainRes = await postFectch(url, data, fetchType.COMMON);
const rainRes = await postFectch(url, data, fetchType.COMMON); // fo
const hasuList = rainRes.data.map((item: any) => {
return {
@@ -176,7 +176,7 @@ export const pubFacActionsExport: (set: any) => pubFacActions = (set: any) => {
// 하수처리시설을 조회한다(운영실태)
pubHasuInit: async (page: string, sggOrCity: string) => {
try {
const pbnstHasuRes = await hasuListFetch(page, sggOrCity);
const pbnstHasuRes = await hasuListFetch(page, sggOrCity); // /prcsFclt/ruseInfo
return set((state: pubFacState) => ({
...state,
pbnst: {
@@ -35,10 +35,11 @@ export type FilesType = {
file: File;
};
const diagramFileTypes = ".pdf,.jpg,.jpeg,.png,.bmp";
/**
* 재이용시설 등록 - 하수재이용 시설등록
* 재이용시설 등록 - 하수재이용 시설현황
* 재이용시설 등록 - 하수재이용 시설현황
* - layout.tsx에 의해 공통 레이아웃이 적용됨
* - with-layout 적용
* - 하수처리시설에 하수재이용 시설 시설 정보를 등록한다.
@@ -459,7 +460,7 @@ export function PbnstMngForm({
setFiles={(files: File[] | null) => {
if (files) fileHandler(files, "relation");
}}
fileTypes=".pdf"
fileTypes={diagramFileTypes}
onFileClick={(index) => {
fileDownLoad(
relationfiles?.find(
@@ -497,7 +498,7 @@ export function PbnstMngForm({
setFiles={(files: File[] | null) => {
if (files) fileHandler(files, "plan");
}}
fileTypes=".pdf"
fileTypes={diagramFileTypes}
onFileClick={(index) => {
fileDownLoad(
planFiles?.find(
@@ -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 충돌 제거)
}
@@ -93,6 +93,7 @@ export const FileUploader = forwardRef<
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".bmp": "image/bmp",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
@@ -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] = 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>;
@@ -119,7 +119,6 @@ export function UiTable({
};
const leafCols = getLeafCols(headlist);
return (
<>
{headName ? (
@@ -263,7 +262,11 @@ export function UiTable({
<TableCell
colSpan={
headlist.filter((item) => item.visible != false).length -
headlist.filter((item) => item.visible != false && item.colSpan).length
headlist.filter(
(item) =>
item.visible != false &&
(item.colSpan || ["A", "B", "C"].includes(item.id)),
).length
}
className="h-24 text-center text-gray-500"
>