정보검색 기본계획보고서 탭 조회 UX 개선

This commit is contained in:
kiminchan
2026-06-30 13:09:07 +09:00
parent 31caee87db
commit c14d536133
12 changed files with 641 additions and 410 deletions
@@ -1,18 +1,19 @@
/**
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭
*
* 📄 Report Tab
* Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트
*
* - 하수도정비 기본계획 보고서 목록 표시 및 필터링
* - 시도/시군구/보고서 구분/승인년도 기반 검색 기능
* - 시도/시군구/구분/보고서 구분/승인년도 기반 검색 기능
* - 테이블 데이터 표시 및 페이지네이션 지원
* - 보고서 클릭 시 파일 목록 모달 열기
* - 파일명 클릭 시 대표 파일 바로 다운로드
* - 탭 진입 시 기본 조건 자동 조회 지원
* - Zustand를 통한 모달 상태 및 데이터 관리
* - 로딩 오버레이 및 선택 초기화 기능
*/
'use client';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Label } from '@common/components';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox, UiTable } from '@common/business_components/ui';
@@ -22,19 +23,17 @@ import useStore from '@/store/useStore';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from '@/store/useReportTabStore';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
// 하수도정비 기본계획 보고서
export const reportTableHeadlist = [
{ id: "no", title: "NO" },
{ id: "sido", title: "시도" },
{ id: "sigungu", title: "시군구" },
{ id: "approvalYear", title: "승인년도" },
{ id: "plantZoneGubun", title: "구분" },
{ id: "reportGubun", title: "보고서구분" },
{ id: "fileName", title: "파일명" },
{ id: "regDate", title: "등록일자" },
{ id: 'no', title: 'NO' },
{ id: 'sido', title: '시도' },
{ id: 'sigungu', title: '시군구' },
{ id: 'approvalYear', title: '승인년도' },
{ id: 'plantZoneGubun', title: '구분' },
{ id: 'reportGubun', title: '보고서구분' },
{ id: 'fileName', title: '파일명' },
{ id: 'regDate', title: '등록일자' },
];
interface ReportTabProps {
open?: boolean;
@@ -43,19 +42,26 @@ interface ReportTabProps {
sggNm?: string;
}
const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => {
const normalizeYear = (value: string) => {
if (!value || value === '0' || value === '9999') return '';
return value;
};
// const [isLoading, setIsLoading] = useState(false);
const getApprovalYearNumber = (value: string | number | undefined) => {
const normalized = String(value ?? '').replace('년', '').trim();
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
};
const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => {
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
const [showTable, setShowTable] = useState(false);
// const [tableData, setTableData] = useState<any[]>([]);
// const [tableCount, setTableCount] = useState(0);
// const [curPage, setCurPage] = useState(1);
// const [yearFilter, setYearFilter] = useState({ start: "", end: "" });
const [ctpvCd, setCtpvCd] = useState('0');
const [sggCd, setSggCd] = useState('0');
const [reportGubun, setReportGubun] = useState("0");
const [plantZoneGubun, setPlantZoneGubun] = useState('0');
const [reportGubun, setReportGubun] = useState('0');
const hasAutoFetchedRef = useRef(false);
const {
tableData,
@@ -73,26 +79,43 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
const {
dcTree: reportOriginData,
fetchDcTree
} = useReportTabStore(); // 모달 상태 관리 스토어
const { REPORT_TYPES, flattenFolders, addFileName } = useReportTab();
} = useReportTabStore();
const { REPORT_TYPES, flattenFolders, addFileName, downloadReportFile } = useReportTab();
const flattenedReportData = useMemo(() => flattenFolders(reportOriginData), [reportOriginData]);
const plantZoneOptions = useMemo(() => {
const uniqueOptions = Array.from(
new Set(
flattenedReportData
.map((item) => String(item.plantZoneGubun ?? '').trim())
.filter((item) => item.length > 0)
)
).sort((a, b) => a.localeCompare(b, 'ko'));
return [
{ val: '0', text: '전체' },
...uniqueOptions.map((item) => ({ val: item, text: item })),
];
}, [flattenedReportData]);
useEffect(() => {
fetchDcTree("기본계획"); // 하수정비기본계획 report
}, []);
void fetchDcTree('기본계획');
}, [fetchDcTree]);
useEffect(() => {
// filterParams 초기화
hasAutoFetchedRef.current = false;
setCtpvCd('0');
setSggCd('0');
setPlantZoneGubun('0');
setReportGubun('0');
setYearFilter({ start: "", end: "" });
setYearFilter({ start: '', end: '' });
setShowTable(false);
setTableData([]);
setTableCount(0);
setCurPage(1); // 페이지를 1로 초기화
setIsResetPage(true); // 페이지를 초기화
setIsResetSelection(true); // 선택 초기화
}, [open]);
setCurPage(1);
setIsResetPage(true);
setIsResetSelection(true);
}, [open, setCurPage, setTableCount, setTableData, setYearFilter]);
useEffect(() => {
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0';
@@ -100,174 +123,189 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
setCtpvCd(newCtpvCd);
setSggCd(newSggCd);
}, [ctpvNm, sggNm]);
}, [ctpv, ctpvNm, sgg, sggNm]);
// ctpvCd, sggCd가 업데이트된 후에 fetchFilteredData 실행
useEffect(() => {
if (!isSearch && ctpvCd && sggCd) {
fetchFilteredData();
}
}, [reportOriginData, ctpvCd, sggCd, isSearch]);
if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current) return;
hasAutoFetchedRef.current = true;
void fetchFilteredData(1);
}, [flattenedReportData.length, open]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
// showTable이 true일 때만 fetch (최초 렌더 방지)
setIsResetPage(false);
if (showTable) fetchFilteredData();
if (showTable) {
void fetchFilteredData();
}
}, [curPage]);
const handleCtpvCdChange = (sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
}
};
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
const handleSearch = () => {
setCurPage(1); // 페이지를 1로 초기화
fetchFilteredData();
setIsResetPage(true); // 페이지를 초기화
setIsResetSelection(false); // 선택 초기화
hasAutoFetchedRef.current = true;
setCurPage(1);
void fetchFilteredData(1);
setIsResetPage(true);
setIsResetSelection(false);
setShowTable(true);
};
// 하수도정비기본계획 테이블 cellClick 핸들러
const handleReportCellClick = (index: number | undefined) => {
if (typeof index === "number" && tableData[index]) {
const reportData = tableData[index];
if (reportData.fileSn !== "") {
const popupWindow = openWindowPopup("reportFiles", {
clsfSystmSn: reportData.clsfSystmSn,
sourcePath: window.location.pathname,
});
const handleReportCellClick = (index: number | undefined, headId: string) => {
if (headId !== 'fileName') return;
if (typeof index !== 'number' || !tableData[index]) return;
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
const reportData = tableData[index];
if (!reportData.fileSn) return;
void (async () => {
setIsLoading(true);
try {
await downloadReportFile(reportData);
} finally {
setIsLoading(false);
}
}
})();
};
// 검색 API 호출 함수
const fetchFilteredData = async () => {
setIsLoading(true); // 로딩 시작
setIsResetSelection(true); // 선택 초기화
const fetchFilteredData = async (page = curPage) => {
setIsLoading(true);
setIsResetSelection(true);
try {
const reportData = flattenFolders(reportOriginData);
let filterReportData = reportData;
let filterReportData = flattenedReportData;
if (ctpvCd !== '0') { // 시도검색
const sidoOption = ctpv.find((n) => n.val.trim() === ctpvCd.trim());
filterReportData = filterReportData.filter(item => item.sido === sidoOption?.text);
if (ctpvCd !== '0') {
const sidoOption = ctpv.find((n) => String(n.val).trim() === ctpvCd.trim());
filterReportData = filterReportData.filter((item) => item.sido === sidoOption?.text);
}
if (sggCd !== '0') { // 시군구검색
if (sggCd !== '0') {
const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd));
filterReportData = filterReportData.filter(item => item.sigungu === sigunguOption?.text);
filterReportData = filterReportData.filter((item) => item.sigungu === sigunguOption?.text);
}
if (reportGubun !== "0") { // 보고서구분검색
if (plantZoneGubun !== '0') {
filterReportData = filterReportData.filter((item) => String(item.plantZoneGubun ?? '') === plantZoneGubun);
}
if (reportGubun !== '0') {
const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun);
filterReportData = filterReportData.filter(item => item.reportGubun === reportGubunOption?.text);
filterReportData = filterReportData.filter((item) => item.reportGubun === reportGubunOption?.text);
}
const { start, end } = yearFilter;
if (start && !end) //승인년도검색
filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" : null);
else if (!start && end)
filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear <= yearFilter.end + "년" : null);
else if (start && end) {
filterReportData = filterReportData.filter(item => {
return (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" && item.approvalYear <= yearFilter.end + "년" : null;
const normalizedStartYear = normalizeYear(yearFilter.start);
const normalizedEndYear = normalizeYear(yearFilter.end);
if (normalizedStartYear || normalizedEndYear) {
filterReportData = filterReportData.filter((item) => {
const approvalYear = getApprovalYearNumber(item.approvalYear);
if (approvalYear === null) return false;
if (normalizedStartYear && approvalYear < Number(normalizedStartYear)) return false;
if (normalizedEndYear && approvalYear > Number(normalizedEndYear)) return false;
return true;
});
}
// if (gubun !== "") { // 구분검색 (추후 사용할 수도 있을 것 같아 남겨둠)
// filterReportData = filterReportData.filter(item => (item.plantZoneGubun) ? String(item.plantZoneGubun).includes(String(gubun)) : null);
// }
setTableCount(filterReportData.length || 0);
filterReportData = filterReportData.slice((curPage - 1) * 10, curPage * 10); // paging
filterReportData = filterReportData?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 }));
const pagedReportData = filterReportData
.slice((page - 1) * 10, page * 10)
.map((item, index) => ({ ...item, no: (page - 1) * 10 + index + 1 }));
addFileName(filterReportData).then(data => {
filterReportData = data;
setTableData(filterReportData || []);
setIsResetSelection(false); // 데이터 설정 후 선택 초기화 해제
});
setTableData(filterReportData || []);
const reportRows = await addFileName(pagedReportData);
setTableData(reportRows || []);
setShowTable(true);
setIsResetSelection(false);
} catch (err) {
console.error('API ERROR:', err);
setTableData([]);
setIsResetSelection(false); // 에러 시에도 선택 초기화 해제
setIsResetSelection(false);
} finally {
setIsLoading(false); // 로딩 종료
setIsLoading(false);
}
};
return (
<>
<div className={`relative flex flex-col border rounded-lg pt-5 px-5 w-full ${isSearch ? "min-h-[10vh]" : "min-h-[6vh]"}`}>
{isSearch && (
<>
<div className="flex items-center mb-3">
<div className="relative mt-4 rounded-lg border px-5 pt-5 pb-3">
<div className="overflow-x-auto">
<div className="flex min-w-max items-center gap-4 whitespace-nowrap">
{isSearch && (
<CtpvSggPrcsFcltSelectBox
className="shrink-0"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId) }}
onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId); }}
onSigunguChange={setSggCd}
setIsLoading={setIsLoading}
/>
)}
<YearRangeSelectBox
className="shrink-0 !mr-1"
selectClass="rounded-lg bg-white flex-shrink-0 w-[78px]"
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) => setYearFilter({
start: normalizeYear(range.start),
end: normalizeYear(range.end),
})}
startLabel="승인년도"
endLabel=""
/>
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">구분</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-white"
label=""
selectArray={plantZoneOptions}
value={plantZoneGubun}
onSelectValue={(val) => setPlantZoneGubun(String(val))}
selectContentClass="z-[101]"
/>
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">보고서 구분</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-white"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun}
onSelectValue={(val) => setReportGubun(String(val))}
selectContentClass="z-[101]"
/>
<div className="ml-auto shrink-0 pl-4">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</>
)}
<div className="flex items-center mb-6">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">보고서 구분</Label>
<SelectBox className="w-44 mr-8"
selectClass="rounded-lg bg-white"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun} onSelectValue={val => setReportGubun(String(val))}
selectContentClass="z-[101]" labelClass='whitespace-nowrap text-xl text-gray-600 pr-2'
/>
{/* 승인년도 필터 */}
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
startLabel="승인년도" endLabel=""
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
<div className="h-[79vh] w-full overflow-hidden rounded-lg border">
{/* 테이블2 영역 (가로/세로 스크롤 가능) */}
<div className="flex-1 px-2 pb-2">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
return (<>조회 결과</>);
})()
}
tableData={tableData}
headlist={reportTableHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleReportCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
<div className="mt-3">
<LoadingOverlay isLoading={isLoading} />
<UiTable
className="!m-0"
clickableHeadIds={["fileName"]}
headName={<>조회 결과</>}
tableData={tableData}
headlist={reportTableHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleReportCellClick}
cellClassName={(row, headId) => {
if (headId !== 'fileName') return '';
if (!row.fileSn) return '!text-gray-400';
return '!text-left !text-blue-600 underline underline-offset-4 hover:!text-blue-800';
}}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
</>
);
};
export default React.memo(ReportTab);
@@ -1,4 +1,4 @@
/**
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창
*
* 📄 Search Modal
@@ -16,22 +16,23 @@ 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 { Toaster } from 'react-hot-toast';
import useSearchModalStore from '@/store/useSearchModalStore';
import { ExclnSewagePumpCol2, 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';
type SearchModalProps = {
renderMode?: "launcher" | "popup";
renderMode?: 'launcher' | 'popup';
autoSearchReady?: boolean;
};
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher", autoSearchReady = false }) => {
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', autoSearchReady = false }) => {
const {
showTable,
isSearchModalOpen, setIsSearchModalOpen,
@@ -65,40 +66,38 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher", auto
operationResultExcelDownload,
handleOperationPageNation
} = useOperationTab();
const hasAutoOpenedReportRef = useRef(false);
const hasAutoSearchedRef = useRef(false);
const hasInitializedTabAutoSearchRef = useRef(false);
const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => {
if (activeTab === 'basic') { // 기본정보
if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;// 하수관로
if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;// 하수처리시설
if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2; // 하수처리구역
if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2; // 하수맨홀
if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2; // 하수펌프장
if (basicType === 'tb_ms_stofcbr') return stofcbrCol2; // 우수토실
if (activeTab === 'basic') {
if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;
if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;
if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2;
if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2;
if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2;
if (basicType === 'tb_ms_stofcbr') return stofcbrCol2;
return [];
}
if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; // 유지관리 정보
if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST;
return [];
}, [activeTab, basicType, workType]);
}, [activeTab, basicType, workType, OPERATION_TABLE_HEADLIST]);
const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => {
if (activeTab === 'operation') {
if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST; // 보수 및 갱신
if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST; // 점검 및 진단
if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST; // 청소 및 준설
if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST;
if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST;
if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST;
}
return [];
}, [activeTab, workType]);
}, [activeTab, workType, WORK_TYPES, MAINTENANACE_TABLE_HEADLIST, CLEANING_TABLE_HEADLIST, INSPECTION_TABLE_HEADLIST]);
useEffect(() => {
if (renderMode !== "launcher" || !isSearchModalOpen) return;
if (renderMode !== 'launcher' || !isSearchModalOpen) return;
const popupWindow = openWindowPopup("mapSearch", {
const popupWindow = openWindowPopup('mapSearch', {
sourcePath: window.location.pathname,
});
@@ -107,59 +106,49 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher", auto
}
setIsSearchModalOpen(false);
}, [renderMode, isSearchModalOpen]);
const openReportTabPopup = () => {
const popupWindow = openWindowPopup("reportTab", {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
useEffect(() => {
if (activeTab !== 'report') {
hasAutoOpenedReportRef.current = false;
return;
}
if (hasAutoOpenedReportRef.current) return;
openReportTabPopup();
hasAutoOpenedReportRef.current = true;
}, [activeTab]);
}, [isSearchModalOpen, renderMode, setIsSearchModalOpen]);
useEffect(() => {
resetFilters();
}, [activeTab, basicType, workType]);
}, [activeTab, basicType, workType, resetFilters]);
useEffect(() => {
if (renderMode !== "popup" || !autoSearchReady || hasAutoSearchedRef.current) return;
if (renderMode !== 'popup' || !autoSearchReady || hasAutoSearchedRef.current) return;
hasAutoSearchedRef.current = true;
hasInitializedTabAutoSearchRef.current = true;
void fetchFilteredData();
}, [autoSearchReady, fetchFilteredData, renderMode]);
// 페이지 변경 시마다 데이터 fetch
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) fetchFilteredData();
if (showTable) {
void fetchFilteredData();
}
}, [curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetDetailSelection(false);
if (showTable) handleOperationPageNation(Number(detailBizId));
if (showTable) {
void handleOperationPageNation(Number(detailBizId));
}
}, [detailCurPage]);
const renderBody = () => (
<>
{/* 탭 영역 */}
<UITab
defaultValue={activeTab}
tabList={TABS.map((tab: any) => ({
@@ -168,69 +157,47 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher", auto
}))}
onValueChange={(value: string) => setActiveTab(value)}
/>
{activeTab === 'basic' && (<BasicTab />)} {/* 기본정보 탭 */}
{activeTab === 'operation' && (<OperationTab />)} {/* 유지관리 정보 탭 */}
{activeTab === 'report' && (
<div className="mt-4 flex h-[79dvh] w-full items-center justify-center rounded-lg border bg-white p-6">
<div className="flex max-w-[520px] flex-col items-center text-center">
<h2 className="mb-3 text-2xl font-semibold text-gray-900">
하수도정비 기본계획보고서
</h2>
<p className="mb-5 text-lg leading-8 text-gray-600">
보고서 상세는 모달 내부 탭 대신
<br />
전용 윈도우 팝업으로 열리도록 전환되었습니다.
</p>
<Button className="px-6 py-2 text-white" onClick={openReportTabPopup}>
전용 팝업 열기
</Button>
</div>
</div>
)} {/* 하수도정비기본계획보고서 탭 */}
{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}
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>
}
/>
<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}
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="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border mt-4">
{/* <div className="flex-1 px-2 pb-2"> */}
<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} 사업 현황</>);
})()
}
headName={(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
return (<>{label} 사업 현황</>);
})()}
tableData={tableData}
headlist={dynamicHeadlist}
pageSize={OPERATION_TABLE_SIZE}
@@ -244,20 +211,15 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher", auto
<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>
<div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
{/* 테이블2 영역 */}
{/* <div className="flex-1 px-2 pb-2"> */}
<LoadingOverlay isLoading={isDetailLoading} />
<UiTable
headName={
(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text
return (<>{label} 결과표</>);
})()
}
headName={(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
return (<>{label} 결과표</>);
})()}
tableData={detailTableData}
headlist={dynamicHeadlist2}
pageSize={OPERATION_TABLE_SIZE}
@@ -274,14 +236,13 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher", auto
</div>
}
/>
{/* </div> */}
</div>
</>
)}
</>
);
if (renderMode === "launcher") {
if (renderMode === 'launcher') {
return null;
}
@@ -291,9 +252,10 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher", auto
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border bg-white p-4">
{renderBody()}
</div>
<Toaster position="top-center"></Toaster>
<Toaster position="top-center" />
</div>
);
};
export default React.memo(SearchModal);
@@ -1,4 +1,4 @@
/**
/**
* 📄 useReportTab Hook
* 검색 모달 보고서 탭 훅
*
@@ -40,6 +40,15 @@ export type ReportDataFile = {
filePath: string;
}
const getStringValue = (value: string | number | undefined) => String(value ?? '');
const buildDisplayFileName = (files: ReportDataFile[]) => {
if (files.length === 0) return '-';
if (files.length === 1) return files[0]?.fileTitle ?? '-';
return `${files[0]?.fileTitle ?? '대표 파일'} 외 ${files.length - 1}건`;
};
export default function useReportTab() {
const REPORT_TYPES = [
{ val: '0', text: '전체' },
@@ -59,11 +68,11 @@ export default function useReportTab() {
*/
const flattenFolders = (data: Folder[]): FlattenedFolder[] => {
const result: FlattenedFolder[] = [];
const exceptNodeNm = ["부분", "통합", "전체"];
const addNodeNm = ["보고서", "요약보고서", "부록", "기타"]; // level5
const exceptNodeNm = ['부분', '통합', '전체'];
const addNodeNm = ['보고서', '요약보고서', '부록', '기타']; // level5
const columnNms: { [key: number]: string } = {
0: "root", 1: "sido", 2: "sigungu", 3: "approvalYear"
, 4: "plantZoneGubun", 5: "reportGubun", 7: "fileName", 8: "regDate"
0: 'root', 1: 'sido', 2: 'sigungu', 3: 'approvalYear',
4: 'plantZoneGubun', 5: 'reportGubun', 7: 'fileName', 8: 'regDate'
};
const traverse = (
@@ -92,28 +101,26 @@ export default function useReportTab() {
newPath.clsfSystmSn = node.clsfSystmSn; // 파일 조회를 위한 고유번호
result.push(newPath);
} else {
if (level === 4) {
if (exceptNodeNm.includes(node.clsfSystmNm)) {
level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함.
}
if (level === 4 && exceptNodeNm.includes(node.clsfSystmNm)) {
level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함.
}
node.children.forEach((child) => {
traverse(child, newPath, level + 1);
});
}
}
};
if (data && data.length > 0 && data[0]?.clsfSystmNm == "기본계획") { // data가 비어있지 않고 children이 있는 경우에만 처리
if (data && data.length > 0 && data[0]?.clsfSystmNm == '기본계획') {
data.forEach((root) => traverse(root, {}, 0));
} else {
data.forEach((root) => traverse(root, {}, 1));
}
return result;
}
};
const getDcFiles = async (clsfSystmSn: string): Promise<ReportDataFile[]> => {
if (!clsfSystmSn) {
console.error("clsfSystmSn is required to get files.");
console.error('clsfSystmSn is required to get files.');
return [];
}
const queryString = 'clsfSystmSn=' + clsfSystmSn;
@@ -126,29 +133,54 @@ export default function useReportTab() {
const fileReportData = await Promise.all(
data.map(async (item) => {
const fileArr: ReportDataFile[] = await getDcFiles(String(item.clsfSystmSn));
let result: FlattenedFolder;
if (fileArr.length > 0) {
const f = fileArr[0];
const fCount = fileArr.length;
result = { ...item, fileName: f?.fileTitle + " 외 " + fCount + "건", regDate: f?.regDt, fileSn: f?.fileSn, filePath: f?.filePath };
} else {
result = { ...item, fileName: "", regDate: "", fileSn: "", filePath: "" };
if (fileArr.length === 0) {
return {
...item,
fileName: '-',
regDate: '',
fileSn: '',
filePath: '',
fileCount: 0,
downloadFileTitle: '',
};
}
return result;
const primaryFile = fileArr[0];
return {
...item,
fileName: buildDisplayFileName(fileArr),
regDate: primaryFile?.regDt ?? '',
fileSn: primaryFile?.fileSn ?? '',
filePath: primaryFile?.filePath ?? '',
fileCount: fileArr.length,
downloadFileTitle: primaryFile?.fileTitle ?? '',
};
})
);
return fileReportData;
};
const getDcFileDownload = async (fileSn: string, fileName: string, filePath: string) => {
if (filePath === "") {
if (filePath === '') {
alert(`filePath 정보가 없으므로 다운로드가 불가합니다.\nfilesn: ${fileSn}`);
return;
}
const queryString = 'filePath=' + encodeURIComponent(filePath);
const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, "blob");
const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, 'blob');
fileDownload(responseBlob, fileName);
}
};
const downloadReportFile = async (item: FlattenedFolder) => {
const fileSn = getStringValue(item.fileSn);
const filePath = getStringValue(item.filePath);
const downloadFileTitle = getStringValue(item.downloadFileTitle || item.fileName);
if (!fileSn || !filePath || !downloadFileTitle || downloadFileTitle === '-') {
return;
}
await getDcFileDownload(fileSn, downloadFileTitle, filePath);
};
// 파일 목록 조회
const getFiles = async () => {
@@ -159,7 +191,7 @@ export default function useReportTab() {
data = data?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 }));
setFiles(data);
} catch (error) {
console.error("파일 목록 조회 에러:", error);
console.error('파일 목록 조회 에러:', error);
} finally {
setIsLoading(false); // 로딩 종료
}
@@ -167,10 +199,10 @@ export default function useReportTab() {
// cellClick 핸들러
const handleCellClick = (index: number | undefined) => {
if (typeof index === "number" && files[index]) {
if (typeof index === 'number' && files[index]) {
const fileName = files[index].fileTitle;
const filePath = files[index].filePath;
if (files[index].fileSn !== "") {
if (files[index].fileSn !== '') {
getDcFileDownload(files[index].fileSn, fileName, filePath);
}
}
@@ -182,7 +214,8 @@ export default function useReportTab() {
addFileName,
getDcFiles,
getDcFileDownload,
downloadReportFile,
getFiles,
handleCellClick
};
}
}
@@ -1,4 +1,4 @@
/**
/**
* 📄 useSearchModal Hook
* 검색 모달 훅
*
@@ -12,10 +12,10 @@
* 🔗 사용 API
* - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service)
*/
import { getData } from "@/components/lib/api";
import { getData } from '@/components/lib/api';
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import useStore from '../../../store/useStore';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import GisApp from '@common/business_components/ui/map/lib/gis.core';
import useSearchModalStore from '../../../store/useSearchModalStore';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
@@ -23,61 +23,78 @@ import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessag
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
typeof window !== 'undefined' &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
window.location.pathname.startsWith('/popup/');
export default function useSearchModal() {
const {
activeTab, workType,
tableData, detailTableData,
curPage,
var1, var2,
yearFilter,
todayYear, todayMonth, todayDate,
activeTab,
tableData,
detailTableData,
setIsSearchModalOpen,
setShowTable,
setTableData, setDetailTableData,
setTableCount, setDetailTableCount,
setTableData,
setCurPage,
setIsLoading,
setIsResetPage, setIsResetDetailPage,
setIsResetSelection, setIsResetDetailSelection,
getDateString
setIsResetPage,
setIsResetDetailPage,
setIsResetSelection,
setIsResetDetailSelection,
getDateString,
} = useSearchModalStore();
const { fetchBasicFilteredData } = useBasicTab();
const { OPERATION_TABLE_SIZE } = useOperationTab();
const { BIZ_TY_CD } = useStore();
const TABS = [
{ val: "basic", text: "기본 정보" },
{ val: "operation", text: "유지관리 정보" },
{ val: "report", text: "하수도 정비 기본계획 보고서" },
{ val: 'basic', text: '기본 정보' },
{ val: 'operation', text: '유지관리 정보' },
{ val: 'report', text: '하수도 정비 기본계획 보고서' },
];
// 검색 API 호출 함수
const fetchFilteredData = async () => {
setIsLoading(true); // 로딩 시작
setIsResetSelection(true); // 선택 초기화
setIsResetDetailSelection(true); // 선택 초기화
const {
activeTab: currentTab,
curPage,
var1,
var2,
yearFilter,
workType,
todayYear,
todayMonth,
todayDate,
setTableData,
setDetailTableData,
setTableCount,
setDetailTableCount,
setShowTable,
setIsLoading,
setIsResetSelection,
setIsResetDetailSelection,
} = useSearchModalStore.getState();
setIsLoading(true);
setIsResetSelection(true);
setIsResetDetailSelection(true);
try {
if (activeTab === 'basic') {
fetchBasicFilteredData();
} else if (activeTab === 'operation') {
const RequestDTO: Record<string, string> = {
if (currentTab === 'basic') {
await fetchBasicFilteredData();
} else if (currentTab === 'operation') {
const requestDto: Record<string, string> = {
page: `${curPage - 1}`,
size: `${OPERATION_TABLE_SIZE}`,
};
if (var1) RequestDTO.bizInfoNm = var1;
if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2;
// 수행기간 범위
if (var1) requestDto.bizInfoNm = var1;
if (var2 !== '0' && var2) requestDto.bizTypeCd = var2;
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
'19000101',
);
const endDate = getDateString(
yearFilter.end,
@@ -85,22 +102,19 @@ export default function useSearchModal() {
'',
`${todayYear}${todayMonth}${todayDate}`,
);
RequestDTO.fromflfmtEndYmd = startDate;
RequestDTO.toflfmtEndYmd = endDate;
// JSON.parse 사용으로 문자열 -> 배열 변환
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const params = new URLSearchParams(RequestDTO).toString();
requestDto.fromflfmtEndYmd = startDate;
requestDto.toflfmtEndYmd = endDate;
requestDto.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const params = new URLSearchParams(requestDto).toString();
const res = await getData(`v1/bizInfo?${params}`);
const mappedContent = (res.content || []).map((item: any, index: number) => {
const formattedItem = formatDisplayData(item);
const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd);
const matched = BIZ_TY_CD.find((code) => code.lwpoCd === item.bizTypeCd);
return {
...formattedItem,
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
};
});
@@ -118,20 +132,19 @@ export default function useSearchModal() {
setTableData([]);
setIsResetSelection(false);
} finally {
setIsLoading(false); // 로딩 종료
setIsLoading(false);
setIsResetSelection(false);
// setIsResetDetailSelection(false);
}
};
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
const handleSearch = () => {
setCurPage(1); // 페이지를 1로 초기화
fetchFilteredData();
setIsResetPage(true); // 페이지를 초기화
setIsResetDetailPage(true); // 디테일 페이지를 초기화
setIsResetSelection(false); // 선택 초기화
setIsResetDetailSelection(false); // 선택 초기화
setCurPage(1);
void fetchFilteredData();
setIsResetPage(true);
setIsResetDetailPage(true);
setIsResetSelection(false);
setIsResetDetailSelection(false);
setShowTable(true);
};
@@ -186,7 +199,7 @@ export default function useSearchModal() {
setIsSearchModalOpen(false);
}
}
}
};
return { TABS, getDateString, handleSearch, viewMultipleFeature, fetchFilteredData };
};
}
@@ -0,0 +1,56 @@
# 정보검색-하수도정비기본계획보고서 통합검증계획
## 1. 문서 목적
- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 검증 계획을 기록한다.
## 2. 검증 범위
- report 탭 추가 팝업 제거 여부
- 조회조건 순서 및 필터 동작
- 탭 전환 시 자동조회 동작
- 파일명 셀 직접 다운로드 동작
- 보고서 행 클릭 제한 동작
- 공통 select 포커스 표시 잘림 여부
- 기존 `dc-service` 호출 유지 여부
## 3. 검증 기준
1. `정보검색` 팝업에서 `report` 탭 선택 시 추가 윈도우 팝업이 열리지 않아야 한다.
2. 조회조건이 `시도 > 시군구 > 승인년도 > 구분 > 보고서 구분` 순서로 보여야 한다.
3. 조회조건이 한 줄 우선 정렬로 보이고 불필요한 하단 공백이 없어야 한다.
4. `기본 정보`, `유지관리 정보`, `하수도 정비 기본계획 보고서` 탭 전환 시 각 탭의 기본조회가 자동 실행되어야 한다.
5. 파일이 있는 행의 `파일명`만 링크 스타일을 가져야 한다.
6. 파일명 클릭 시 별도 파일목록 팝업 없이 다운로드가 시작되어야 한다.
7. 파일명 외 다른 셀 클릭 시 추가 동작이 없어야 한다.
8. `시도`, `시군구`, `승인년도`, `구분`, `보고서 구분` select 포커스 표시가 잘리지 않아야 한다.
9. Network에서 `dc-service`의 `clsf`, `file`, `fileDownload` 호출이 유지되어야 한다.
## 4. 라운드별 검증 계획
### 4.1 정적 검증
- 수정 파일 import/상태 흐름 재검토
- 기존 popup 호출 제거 여부 검색
- `UiTable` 옵션 추가가 다른 화면 기본동작을 깨지 않는지 코드 기준 확인
### 4.2 명령 검증
- `pnpm --filter sn lint`
- 목적: 타입/구문/기본 lint 문제 확인
### 4.3 수동 브라우저 검증
- 분석도구 `정보검색` 클릭
- `기본 정보`, `유지관리 정보`, `하수도 정비 기본계획 보고서` 순서로 탭 전환
- 각 탭 진입 시 버튼 없이 기본조회 자동 실행 확인
- 보고서 탭 조건 변경 후 조회
- 파일명 클릭 다운로드 확인
- 파일명 외 셀 클릭 무반응 확인
- 기본정보/유지관리/보고서 탭의 select 포커스 잘림 여부 확인
- 하수처리시설 상세의 `조회` 버튼으로 여는 `reportTab` 팝업 회귀 확인
## 5. 차단 사유 분류 기준
- 코드 문제: 타입 오류, 런타임 오류, 잘못된 자동조회/필터/다운로드 동작
- 환경 문제: pnpm 설치 정책, lockfile 설정, TTY 제약, 개발 서버 충돌
@@ -0,0 +1,37 @@
# 정보검색-하수도정비기본계획보고서 통합검증결과
## 1. 문서 목적
- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 검증 수행 결과를 기록한다.
## 2. 수행한 검증
### 2.1 정적 확인
- `report` 탭 자동 팝업 오픈 제거 반영 여부 확인
- `reportFiles` 팝업 호출 제거 반영 여부 확인
- 파일명 직접 다운로드 로직 연결 여부 확인
- 탭 전환 시 자동조회 흐름 추가 여부 확인
- `UiTable`의 `clickableHeadIds` 옵션이 미지정 화면에는 영향이 없는 구조인지 확인
- 공통 `SelectBox` 포커스 보정이 style 변경 수준인지 확인
### 2.2 명령 실행
- 2026-06-30: `pnpm --filter sn lint`
- 1차 결과: 샌드박스 임시 파일 권한 오류로 실패
- 2차 결과: `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`로 실패
- 3차 결과: `CI=true` 재시도 후 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`로 실패
## 3. 현재 검증 상태
- 코드 반영: 완료
- 정적 구조 확인: 완료
- 자동 lint 검증: 환경 문제로 미완료
- 브라우저 수동 검증: 미실행
## 4. 보류/리스크
1. 현재 워크스페이스 pnpm 환경이 lockfile 설정 불일치로 자동 lint를 진행하지 못한다.
2. 브라우저 수동 검증이 남아 있어 탭별 자동조회 체감, select 포커스 표시, 여백 보정 체감은 아직 확인하지 못했다.
3. 다건 파일 대표 다운로드 기준은 현재 첫 번째 파일 기준이다.
4. `reportFiles` 팝업 관련 미사용 코드 정리는 후속 정리 대상으로 남긴다.
@@ -0,0 +1,82 @@
# 정보검색-하수도정비기본계획보고서 통합구현기록
## 1. 문서 목적
- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 구현 내용을 기록한다.
## 2. 입력 근거
- 공통 설계: `IIPBS/docs/작업계획/SN-정보검색-하수도정비기본계획보고서-유지보수/001-de-정보검색-하수도정비기본계획보고서-설계.md`
- 사용자 요구사항
- report 탭 조회결과를 기존 영역에 표시
- 파일명 클릭 즉시 다운로드
- 조회조건 순서 변경
- 조회조건 한 줄 정렬 및 여백 보정
- 탭 전환 시 기본조회
- select 포커스 잘림 보정
## 3. 공통 계약
- 기존 `dc-service` 프록시 계약 `clsf`, `file`, `fileDownload`를 그대로 사용한다.
- `sewerage-service` 신규 API와 DB 변경은 이번 범위에서 수행하지 않는다.
- 공통 컴포넌트 수정은 `sn` 요구사항을 위한 옵션 추가 또는 스타일 보정으로만 한정한다.
## 4. 구현 라운드 요약
### 4.1 report 탭 인라인 복구
- `SearchModal`에서 `report` 탭 선택 시 전용 팝업을 자동으로 열던 흐름을 제거했다.
- 안내 카드 대신 `ReportTab`을 같은 본문 영역에 직접 렌더링하도록 바꿨다.
### 4.2 파일명 직접 다운로드 전환
- `ReportTab`의 셀 클릭을 `파일명` 컬럼으로 한정했다.
- 파일이 있는 행의 `파일명` 셀에 파란 글씨, 밑줄, 포인터 스타일을 적용했다.
- 파일 클릭 시 `dc-service` 파일 다운로드를 바로 호출하도록 연결했다.
- 다건 파일은 현재 구조상 대표 파일 정의가 필요해서 첫 번째 파일을 대표 다운로드 대상으로 사용했다.
- 표시 문자열은 `1건이면 실제 파일명`, `2건 이상이면 첫 파일명 외 N건`으로 보정했다.
### 4.3 조회조건 재배치 및 필터 보강
- 조회조건 표시 순서를 `시도 > 시군구 > 승인년도 > 구분 > 보고서 구분`으로 맞췄다.
- 기존에 조회 UI에 없던 `구분(plantZoneGubun)` 필터를 추가했다.
- 승인년도는 `0`, `9999` 같은 placeholder 값을 실제 필터에서 제외하도록 정리했다.
### 4.4 탭 기본조회 보강
- 팝업 최초 진입 시 1회 자동조회만 하던 흐름을 탭 전환 기준으로 보강했다.
- `기본 정보`, `유지관리 정보`는 탭 또는 대표 조건이 바뀔 때 기본조건 조회가 다시 돌도록 `SearchModal/useSearchModal` 공통 흐름을 정리했다.
- `하수도 정비 기본계획 보고서`는 `ReportTab` 내부에서 탭 진입 시 1회 자동조회되도록 유지했다.
### 4.5 조회조건 레이아웃 및 결과 영역 정리
- 보고서 탭 조회조건은 한 줄 우선 정렬로 재배치하고, 화면이 좁을 때는 줄바꿈 대신 가로 스크롤이 나도록 바꿨다.
- 조회조건 박스의 과한 최소 높이와 하단 여백을 줄여 빈 줄처럼 보이던 공간을 제거했다.
- 결과 영역은 고정 높이 wrapper를 제거하고 `UiTable` 자체 레이아웃을 사용하게 정리해 상하 공백을 줄였다.
- 최종 보정으로 조회조건 각 요소 간 간격을 조금 더 벌려 보이도록 조정했다.
### 4.6 행 클릭 제한 및 공통 select 포커스 보정
- 보고서 표는 이제 행 전체가 아니라 `파일명` 컬럼만 클릭되도록 `UiTable`에 `clickableHeadIds` 옵션을 추가했다.
- 이 옵션은 기본 미지정 시 기존 동작을 유지하므로 다른 화면에 영향이 없다.
- 공통 `SelectBox`는 포커스 outline이 위에서 잘리지 않도록 root에 `overflow-visible`과 세로 여유를 주고, trigger 높이를 조금 늘렸다.
- 추가로 포커스 ring을 바깥이 아닌 안쪽(`ring-inset`)으로 바꿔 기본정보/유지관리/보고서 탭 모두에서 잘림 없이 보이도록 보정했다.
## 5. 수정 파일
- `frontend/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx`
- `frontend/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx`
- `frontend/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts`
- `frontend/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts`
- `frontend/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx`
- `frontend/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx`
- `frontend/packages/common/business_components/src/ui/table/index.tsx`
- `frontend/packages/common/business_components/src/ui/select/select.css`
- `frontend/packages/common/business_components/src/ui/select/lib/selectBox.tsx`
## 6. 남은 리스크
- 다건 파일 대표 다운로드 기준은 현재 첫 번째 파일 기준이다.
- `reportFiles` 윈도우 팝업 코드는 현재 미호출 상태로 남아 있다.
- lint/build 자동 검증은 워크스페이스 pnpm 환경 문제로 완료하지 못했다.
- 브라우저에서 최신 select 포커스 스타일과 자동조회 체감은 추가 수동 확인이 필요하다.
@@ -1,4 +1,7 @@
"use client";
import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab";
import { useEffect, useState } from "react";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import { getPopupParamValue } from "./popupContentUtils";
@@ -6,9 +9,18 @@ import { getPopupParamValue } from "./popupContentUtils";
export default function ReportTabPopupContent({
searchParams,
}: WindowPopupContentProps) {
const [isMounted, setIsMounted] = useState(false);
const ctpvNm = getPopupParamValue(searchParams.ctpvNm);
const sggNm = getPopupParamValue(searchParams.sggNm);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="h-dvh bg-white" suppressHydrationWarning />;
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
@@ -1,10 +1,11 @@
"use client";
"use client";
import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal";
import useStore from "@/store/useStore";
import { useEffect } from "react";
import { useEffect, useState } from "react";
export default function SearchModalPopupContent() {
const [isMounted, setIsMounted] = useState(false);
const {
ctpv,
sgg,
@@ -17,15 +18,27 @@ export default function SearchModalPopupContent() {
} = useStore();
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
// Popup windows do not run map-layout initialization, so load location codes here.
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
}, [ctpv.length, fetchCtpv, fetchSgg, isMounted, sgg.length]);
useEffect(() => {
if (!isMounted) return;
if (BIZ_SE_CD.length === 0) void fetchBIZ_SE_CD();
if (BIZ_TY_CD.length === 0) void fetchBIZ_TY_CD();
}, [BIZ_SE_CD.length, BIZ_TY_CD.length, fetchBIZ_SE_CD, fetchBIZ_TY_CD]);
}, [BIZ_SE_CD.length, BIZ_TY_CD.length, fetchBIZ_SE_CD, fetchBIZ_TY_CD, isMounted]);
if (!isMounted) {
return <div className="h-dvh bg-white" suppressHydrationWarning />;
}
return <SearchModal renderMode="popup" autoSearchReady={ctpv.length > 0 && sgg.length > 0} />;
}
@@ -1,4 +1,4 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from "react"
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-13 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xl ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-14 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xl ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
@@ -148,3 +148,5 @@ export {
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue
}
@@ -1,8 +1,8 @@
@tailwind components;
@tailwind components;
@layer components {
.selectbox_root {
@apply flex items-center;
@apply flex items-center overflow-visible py-1;
}
.selectbox_label {
@@ -1,4 +1,4 @@
"use client";
"use client";
import {
Checkbox,
@@ -37,6 +37,7 @@ type UiTableProps<T extends object> = {
headButton?: React.ReactNode | undefined;
widthFixed?: boolean;
isHideCount?: boolean; // 총 건수 default false
clickableHeadIds?: string[];
};
type UiHeadProps = {
@@ -69,7 +70,8 @@ export function UiTable<T extends object = Record<string, unknown>>({
curPage = 1,
headButton = undefined,
widthFixed = false,
isHideCount = false, // 총 건수
isHideCount = false,
clickableHeadIds,
}: UiTableProps<T>) {
const [cur, setCur] = useState<number>(curPage);
const [idx, setIdx] = useState<number>(index);
@@ -97,32 +99,36 @@ export function UiTable<T extends object = Record<string, unknown>>({
useEffect(() => {
setIdx(index);
}, [index])
}, [index]);
useEffect(() => {
setCur(curPage);
}, [curPage])
}, [curPage]);
const handlePageChange = (page: number) => {
setCur(page);
onPageChange(page);
if (index == 0)
setIdx(0)
if (index == 0) setIdx(0);
};
const isHeadClickable = (headId: string) => {
if (!cellClick) return false;
if (!clickableHeadIds || clickableHeadIds.length === 0) return true;
return clickableHeadIds.includes(headId);
};
const handleCellClick = (index: number, headId: string) => {
if (cellClick) {
cellClick(index, headId);
setIdx(index);
}
if (!isHeadClickable(headId) || !cellClick) return;
cellClick(index, headId);
setIdx(index);
};
const getLeafCols = (list: UiHeadProps[]) => {
const maxRow = list?.reduce((max, head) => Math.max(max, head.row || 0), 0);
return list
.filter(h => h.visible !== false)
.filter(h => ((h.row ?? 0) + (h.rowSpan ?? 1) - 1) >= maxRow)
;
.filter((h) => h.visible !== false)
.filter((h) => ((h.row ?? 0) + (h.rowSpan ?? 1) - 1) >= maxRow);
};
const leafCols = getLeafCols(headlist);
@@ -153,12 +159,15 @@ export function UiTable<T extends object = Record<string, unknown>>({
)}
<div className={`card !p-0r !m-[1.2rem] ${className}`}>
<Table variant="secondary" className={widthFixed ? "table-fixed" : ""}>
{widthFixed ?
{widthFixed ? (
<colgroup>
{leafCols.map(col => (
{leafCols.map((col) => (
<col key={col.id} style={{ width: col.widthPercent ? `${col.widthPercent}%` : undefined }} />
))}
</colgroup> : ""}
</colgroup>
) : (
""
)}
<TableHeader ref={headerRef} className="font-bold">
{children ? children : <HeadMakeColSpan upHeadList={headlist} />}
</TableHeader>
@@ -174,9 +183,9 @@ export function UiTable<T extends object = Record<string, unknown>>({
head.id.length > 1 &&
(boldArray.includes(index) ? (
<TableCell
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${cellClick && idx === index
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${isHeadClickable(head.id) && idx === index
? "cursor-pointer !font-[1000]"
: cellClick
: isHeadClickable(head.id)
? "cursor-pointer"
: ""
} ${cellClassName ? cellClassName(el, head.id, index) : ""} sticky top-20 z-40 bg-secondary !font-bold whitespace-pre-line`}
@@ -190,55 +199,38 @@ export function UiTable<T extends object = Record<string, unknown>>({
? String(cellValue ?? "")
: String(cellValue).includes(".")
? String(cellValue)
.split(".")
.map((part, index) =>
index === 0
? part.replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
: part,
)
.join(".")
: String(cellValue ?? "").replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
);
.split(".")
.map((part, index) =>
index === 0
? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
: part,
)
.join(".")
: String(cellValue ?? "").replace(/\B(?=(\d{3})+(?!\d))/g, ",");
})()}
</TableCell>
) : (
<TableCell
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${cellClick && idx === index
className={`${head.align === "right" ? "text-right" : head.align === "center" ? "text-center" : "text-center"} ${isHeadClickable(head.id) && idx === index
? "cursor-pointer !font-[1000]"
: cellClick
: isHeadClickable(head.id)
? "cursor-pointer"
: ""
} ${cellClassName ? cellClassName(el, head.id, index) : ""} mt-[${boldArray.length * 40}px] whitespace-pre-line`}
onClick={() => handleCellClick(index, head.id)}
key={`cell-${head.id}-${index}`}
>
{
//체크박스 유형애서 readOnly가 필요한 경우를 위해서 ChkClass 를 사용해서 readOnly 처리함.
(() => {
const cellValue = (el as Record<string, unknown>)[head.id];
return typeof cellValue === "object" &&
cellValue !== null &&
"chk" in (cellValue as object) &&
"readonly" in (cellValue as object) ? (
{(() => {
const cellValue = (el as Record<string, unknown>)[head.id];
return typeof cellValue === "object" &&
cellValue !== null &&
"chk" in (cellValue as object) &&
"readonly" in (cellValue as object) ? (
<Checkbox
checked={
(cellValue as ChkClass)?.chk === true
}
checked={(cellValue as ChkClass)?.chk === true}
onCheckedChange={(checked) => {
{
console.log(cellValue);
}
if (
(cellValue as ChkClass).readonly == true
)
return;
(cellValue as ChkClass).chk =
Boolean(checked);
if ((cellValue as ChkClass).readonly == true) return;
(cellValue as ChkClass).chk = Boolean(checked);
}}
>
<Check className="h-6 w-6 text-primary" />
@@ -258,22 +250,13 @@ export function UiTable<T extends object = Record<string, unknown>>({
String(cellValue)
.split(".")
.map((part, index) =>
index === 0
? part.replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
: part,
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part,
)
.join(".")
) : (
String(cellValue ?? "").replace(
/\B(?=(\d{3})+(?!\d))/g,
",",
)
String(cellValue ?? "").replace(/\B(?=(\d{3})+(?!\d))/g, ",")
);
})()
}
})()}
</TableCell>
)),
)}