Files
frontend/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx
T

383 lines
15 KiB
TypeScript

/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창
*
* 📄 Search Modal
* Next.js App Router (app/) 기반 검색 모달 컴포넌트
*
* - 탭 기반 검색 인터페이스 (기본정보, 유지관리 정보, 보고서)
* - 테이블 데이터 표시 및 페이지네이션 기능
* - 시설물 조회 결과 및 유지관리 사업 현황 표시
* - 엑셀 다운로드 및 지도에서 보기 기능
* - Zustand를 통한 검색 모달 상태 관리
* - 로딩 오버레이 및 선택 초기화 지원
*/
'use client';
import LoadingOverlay from '@/components/ui/LoadingBar';
import { UITab, UiTable } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import React, { useEffect, useMemo, useRef } from 'react';
import { Toaster } from 'react-hot-toast';
import useSearchModalStore from '@/store/useSearchModalStore';
import { ExclnSewagePumpCol2, FldngWtlvMapCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table';
import BasicTab, { BASIC_TYPES } from './basicTab';
import OperationTab from './operationTab';
import ReportTab from './reportTab';
import useSearchModal from './useSearchModal';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import type { UiHeadProps, UiTableSort } from '@common/business_components/ui';
type SearchModalProps = {
renderMode?: 'launcher' | 'popup';
autoSearchReady?: boolean;
};
// 기본정보 표시 필드 중 GeoServer 원천 속성으로 정확히 정렬할 수 있는 필드만 매핑한다.
const BASIC_SORT_KEYS: Record<string, Record<string, string>> = {
tb_ms_swage_ppl: {
rowNumber: 'rowNumber',
fcltMngNo: 'fclt_mng_no', instMngNo: 'inst_mng_no', instlYmd: 'instl_ymd',
ctpvNm: 'pbadms_emd_cd',
sggNm: 'pbadms_emd_cd', swtrmt_zone_nm: 'swtrmt_zone_nm', usgCd: 'usg_cd',
pdmtVl: 'pdmt_vl', prlgVl: 'prlg_vl', tmptDpth: 'tmpt_dpth',
tmptMhlMngNo: 'tmpt_mhl_mng_no', scendDpth: 'scend_dpth',
scendMhlMngNo: 'scend_mhl_mng_no', vrtcLen: 'vrtc_len', wdthLen: 'wdth_len',
fcltyShpCd: 'fclty_shp_cd', mqltCd: 'mqlt_cd',
},
tb_il_swage_prcs_fclt: {
rowNumber: 'rowNumber',
swtrmtFcltNm: 'swtrmt_fclt_nm', fcltCpct: 'fclt_cpct', rgnExpln: 'rgn_expln',
oprtngStrtYmd: 'oprtng_strt_ymd', laeNm: 'lae_nm', sareaNm: 'sarea_nm',
rvrNm: 'rvr_nm', dschgWtstNm: 'dschg_wtst_nm', swtrmtPpltnCnt: 'swtrmt_ppltn_cnt',
rprsMsrcCn: 'rprs_msrc_cn',
},
tb_ms_swage_prcs_zone: {
rowNumber: 'rowNumber',
swtrmtZoneNm: 'swtrmt_zone_nm', ctpvNm: 'ctpv_nm', sggNm: 'sgg_nm',
swtrmtFcltNm: 'swtrmt_fclt_nm',
},
tb_ms_fldng_wtlv_map: {
rowNumber: 'rowNumber',
fldngYr: 'fldng_yr', fldngDstrNm: 'fldng_dstr_nm', ctpvNm: 'stdg_cd', sggNm: 'stdg_cd',
bgngYmd: 'bgng_ymd', fldngBgngHm: 'fldng_bgng_hm', endYmd: 'end_ymd', fldngEndHm: 'fldng_end_hm',
fldngHr: 'fldng_hr', fldngDstrctNm: 'fldng_dstrct_nm', fldngCsDtlCd: 'fldng_cs_dtl_cd',
avgFldngWtlv: 'avg_fldng_wtlv', fldngArea: 'fldng_area', exmnYr: 'exmn_yr', damAmt: 'dam_amt',
},
tb_ms_swage_mhl: {
rowNumber: 'rowNumber',
fcltMngNo: 'fclt_mng_no', instlYmd: 'instl_ymd', swageMhlDiamVl: 'swage_mhl_diam_vl',
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd', usgCd: 'usg_cd',
wdthLen: 'wdth_len', vrtcLen: 'vrtc_len', altdVl: 'altd_vl', dpthlVl: 'dpthl_vl',
},
tb_ms_swage_pmstn: {
rowNumber: 'rowNumber',
fcltMngNo: 'fclt_mng_no', swagePmstnNm: 'swage_pmstn_nm', instlYmd: 'instl_ymd',
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd',
dailyPrcsCpct: 'daily_prcs_cpct', maxImpndCpct: 'max_impnd_cpct', altdVl: 'altd_vl',
wtlvVl: 'wtlv_vl', exclnWpmpAbltVl: 'excln_wpmp_ablt_vl',
},
tb_ms_stofcbr: {
rowNumber: 'rowNumber',
fcltMngNo: 'fclt_mng_no', mapshtNo: 'mapsht_no', instlYmd: 'instl_ymd',
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd',
worflwWallHgt: 'worflw_wall_hgt', swageMhlMngNo: 'swage_mhl_mng_no', drtnAngl: 'drtn_angl',
},
};
const OPERATION_LIST_SORT_KEYS: Record<string, string> = {
rowNumber: 'rowNumber', bizInfoNm: 'bizInfoNm', flfmtBgngYmd: 'flfmtBgngYmd',
flfmtEndYmd: 'flfmtEndYmd', flfmtYr: 'flfmtYr', bizTypeCd: 'bizTypeCd', bizAmt: 'bizAmt',
};
const OPERATION_DETAIL_SORT_KEYS: Record<string, string> = {
rowNumber: 'rowNumber',
fcltMngNo: 'fclt_mng_no',
swtrmt_zone_nm: 'swtrmt_zone_nm',
usgCd: 'usg_cd',
mqltCd: 'mqlt_cd',
pdmtVl: 'pdmt_vl',
prlgVl: 'prlg_vl',
instlYmd: 'instl_ymd',
strc_stts_grd: 'strc_stts_grd',
oper_stts_grd: 'oper_stts_grd',
};
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', autoSearchReady = false }) => {
const {
showTable,
isSearchModalOpen, setIsSearchModalOpen,
activeTab, basicType, workType,
tableData, detailTableData,
tableCount, detailTableCount,
curPage, detailCurPage,
detailBizId,
isLoading, isDetailLoading,
isResetPage, isResetDetailPage,
isResetSelection, isResetDetailSelection,
setActiveTab,
setCurPage, setDetailCurPage,
setIsResetPage, setIsResetDetailPage,
setIsResetSelection,
setDetailBizId,
setIsResetDetailSelection,
sort, detailSort,
setSort, setDetailSort,
resetFilters
} = useSearchModalStore();
const { TABS, viewMultipleFeature, fetchFilteredData } = useSearchModal();
const { handleBasicCellClick, basicExcelDownload } = useBasicTab();
const {
WORK_TYPES,
OPERATION_TABLE_SIZE,
OPERATION_TABLE_HEADLIST,
MAINTENANACE_TABLE_HEADLIST,
CLEANING_TABLE_HEADLIST,
INSPECTION_TABLE_HEADLIST,
handleOperationCellClick,
handleOperationResultCellClick,
operationExcelDownload,
operationResultExcelDownload,
handleOperationPageNation
} = useOperationTab();
const hasAutoSearchedRef = useRef(false);
const hasInitializedTabAutoSearchRef = useRef(false);
const dynamicHeadlist: UiHeadProps[] = useMemo(() => {
if (activeTab === 'basic') {
const headlist = basicType === 'tb_ms_swage_ppl' ? MsSwagePplCol2
: basicType === 'tb_il_swage_prcs_fclt' ? SwtrmtFcltCol2
: basicType === 'tb_ms_swage_prcs_zone' ? SwtrmtPrcsZoneCol2
: basicType === 'tb_ms_fldng_wtlv_map' ? FldngWtlvMapCol2
: basicType === 'tb_ms_swage_mhl' ? MsSwageMhlCol2
: basicType === 'tb_ms_swage_pmstn' ? ExclnSewagePumpCol2
: basicType === 'tb_ms_stofcbr' ? stofcbrCol2 : [];
const sortKeys = BASIC_SORT_KEYS[basicType] ?? {};
return headlist.map((head) => sortKeys[head.id]
? { ...head, sortable: true, sortKey: sortKeys[head.id] }
: head);
}
if (activeTab === 'operation') {
return OPERATION_TABLE_HEADLIST.map((head) => OPERATION_LIST_SORT_KEYS[head.id]
? { ...head, sortable: true, sortKey: OPERATION_LIST_SORT_KEYS[head.id] }
: head);
}
return [];
}, [activeTab, basicType, workType, OPERATION_TABLE_HEADLIST]);
const dynamicHeadlist2: UiHeadProps[] = useMemo(() => {
if (activeTab === 'operation') {
const headlist = workType === WORK_TYPES[0]?.val ? MAINTENANACE_TABLE_HEADLIST
: workType === WORK_TYPES[1]?.val ? CLEANING_TABLE_HEADLIST
: workType === WORK_TYPES[2]?.val ? INSPECTION_TABLE_HEADLIST : [];
return headlist.map((head) => OPERATION_DETAIL_SORT_KEYS[head.id]
? { ...head, sortable: true, sortKey: OPERATION_DETAIL_SORT_KEYS[head.id] }
: head);
}
return [];
}, [activeTab, workType, WORK_TYPES, MAINTENANACE_TABLE_HEADLIST, CLEANING_TABLE_HEADLIST, INSPECTION_TABLE_HEADLIST]);
// 목록 정렬 변경 시 첫 페이지에서 같은 검색조건으로 다시 조회한다.
const handleSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setIsResetPage(true);
setIsResetSelection(true);
setIsResetDetailPage(true);
setDetailSort(undefined);
setDetailBizId(0);
if (curPage === 1) {
void fetchFilteredData(nextSort);
return;
}
setCurPage(1);
};
// 유지관리 상세 정렬은 상단 사업 목록과 분리하고 선택된 사업 범위만 재조회한다.
const handleDetailSortChange = (nextSort?: UiTableSort) => {
setDetailSort(nextSort);
setIsResetDetailPage(true);
setIsResetDetailSelection(true);
if (!detailBizId) {
return;
}
if (detailCurPage === 1) {
void handleOperationPageNation(Number(detailBizId), nextSort);
return;
}
setDetailCurPage(1);
};
useEffect(() => {
if (renderMode !== 'launcher' || !isSearchModalOpen) return;
const popupWindow = openWindowPopup('mapSearch', {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
setIsSearchModalOpen(false);
}, [isSearchModalOpen, renderMode, setIsSearchModalOpen]);
useEffect(() => {
resetFilters();
}, [activeTab, basicType, workType, resetFilters]);
useEffect(() => {
if (renderMode !== 'popup' || !autoSearchReady || hasAutoSearchedRef.current) return;
hasAutoSearchedRef.current = true;
hasInitializedTabAutoSearchRef.current = true;
void fetchFilteredData();
}, [autoSearchReady, fetchFilteredData, renderMode]);
useEffect(() => {
if (renderMode !== 'popup' || !autoSearchReady) return;
if (!hasInitializedTabAutoSearchRef.current) return;
if (activeTab === 'report') return;
setCurPage(1);
setIsResetPage(true);
setIsResetDetailPage(true);
void fetchFilteredData();
}, [activeTab, basicType, workType]);
useEffect(() => {
setIsResetPage(false);
setIsResetDetailPage(true);
setDetailBizId(0);
if (showTable) {
void fetchFilteredData();
}
}, [curPage]);
useEffect(() => {
setIsResetDetailSelection(false);
if (showTable) {
void handleOperationPageNation(Number(detailBizId));
}
}, [detailCurPage]);
const renderBody = () => (
<>
<UITab
defaultValue={activeTab}
tabList={TABS.map((tab: any) => ({
value: tab.val,
label: tab.text
}))}
onValueChange={(value: string) => setActiveTab(value)}
/>
{activeTab === 'basic' && (<BasicTab />)}
{activeTab === 'operation' && (<OperationTab />)}
{activeTab === 'report' && (<ReportTab open={activeTab === 'report'} />)}
{activeTab === 'basic' && (
<div className="mt-4 h-[79dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isLoading} />
<UiTable
className='overflow-x-auto [&_table]:min-w-full [&_table]:w-max'
headName={(() => {
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text;
return (<>{label} 조회 결과</>);
})()}
tableData={tableData}
headlist={dynamicHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleBasicCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
curPage={curPage}
sort={sort}
onSortChange={handleSortChange}
headButton={
<div className='flex'>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={basicExcelDownload}>파일 다운로드</Button>
</div>
}
/>
</div>
)}
{activeTab === 'operation' && (
<>
<div className="mt-4 h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
return (<>{label} 사업 현황</>);
})()}
tableData={tableData}
headlist={dynamicHeadlist}
pageSize={OPERATION_TABLE_SIZE}
total={tableCount}
cellClick={handleOperationCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
curPage={curPage}
sort={sort}
onSortChange={handleSortChange}
headButton={
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-sm font-medium green" size="sm" onClick={operationExcelDownload}>파일 다운로드</Button>
}
/>
</div>
<div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isDetailLoading} />
<UiTable
headName={(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
return (<>{label} 결과표</>);
})()}
tableData={detailTableData}
headlist={dynamicHeadlist2}
pageSize={OPERATION_TABLE_SIZE}
total={detailTableCount}
cellClick={handleOperationResultCellClick}
onPageChange={setDetailCurPage}
resetPageNo={isResetDetailPage}
resetSelection={isResetDetailSelection}
curPage={detailCurPage}
sort={detailSort}
onSortChange={handleDetailSortChange}
headButton={
<div className='flex'>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={operationResultExcelDownload}>파일 다운로드</Button>
</div>
}
/>
</div>
</>
)}
</>
);
if (renderMode === 'launcher') {
return null;
}
return (
<div className='h-dvh overflow-x-auto overflow-y-hidden bg-container p-4 text-foreground'>
<h1 className="mb-3 text-2xl font-semibold text-foreground">관망도 검색</h1>
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border border-border bg-background p-4">
{renderBody()}
</div>
<Toaster position="top-center" />
</div>
);
};
export default React.memo(SearchModal);