/** * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 * * 📄 Report Tab * Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트 * * - 하수도정비 기본계획 보고서 목록 표시 및 필터링 * - 시도/시군구/구분/보고서 구분/승인년도 기반 검색 기능 * - 테이블 데이터 표시 및 페이지네이션 지원 * - 파일명 클릭 시 대표 파일 바로 다운로드 * - 탭 진입 시 기본 조건 자동 조회 지원 * - Zustand를 통한 모달 상태 및 데이터 관리 * - 로딩 오버레이 및 선택 초기화 기능 */ "use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { Button, Label } from "@common/components"; import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox"; import { SelectBox } from "@common/business_components/ui/select"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import useReportTab from "./useReportTab"; import useStore from "@/store/useStore"; import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; import useSearchModalStore from "@/store/useSearchModalStore"; import { useReportTabStore } from "@/store/useReportTabStore"; import type { FlattenedFolder, ReportDataFile } from "./useReportTab"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; // 하수도정비 기본계획 보고서 export const reportTableHeadlist = [ { id: "no", title: "순번" }, { 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; isSearch?: boolean; ctpvNm?: string; sggNm?: string; } const normalizeYear = (value: string) => { if (!value || value === "0" || value === "9999") return ""; return value; }; const getApprovalYearNumber = (value: string | number | undefined) => { const normalized = String(value ?? "") .replace("년", "") .trim(); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; }; const REPORT_PAGE_SIZE = 10; const REPORT_METADATA_CONCURRENCY = 8; const REPORT_METADATA_TIMEOUT_MS = 8000; type ReportSearchFilters = { ctpvCd: string; sggCd: string; plantZoneGubun: string; reportGubun: string; yearFilter: { start: string; end: string }; }; const DEFAULT_REPORT_SEARCH_FILTERS: ReportSearchFilters = { ctpvCd: "0", sggCd: "0", plantZoneGubun: "0", reportGubun: "0", yearFilter: { start: "", end: "" }, }; type ReportFileMetadata = Pick< FlattenedFolder, | "fileName" | "regDate" | "fileSn" | "filePath" | "fileCount" | "downloadFileTitle" >; const EMPTY_REPORT_METADATA: ReportFileMetadata = { fileName: "-", regDate: "", fileSn: "", filePath: "", fileCount: 0, downloadFileTitle: "", }; const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sggNm, }) => { const [showTable, setShowTable] = useState(false); const [ctpvCd, setCtpvCd] = useState("0"); const [sggCd, setSggCd] = useState("0"); const [plantZoneGubun, setPlantZoneGubun] = useState("0"); const [reportGubun, setReportGubun] = useState("0"); const hasAutoFetchedRef = useRef(false); const fileMetadataCacheRef = useRef>( new Map(), ); const fileMetadataPromiseCacheRef = useRef< Map> >(new Map()); const { tableData, tableCount, isLoading, yearFilter, curPage, setTableData, setTableCount, setIsLoading, setYearFilter, setCurPage, } = useSearchModalStore(); const { ctpv, sgg } = useStore(); const { dcTree: reportOriginData, fetchDcTree } = useReportTabStore(); const { REPORT_TYPES, flattenFolders, getDcFiles, downloadReportFile } = useReportTab(); const flattenedReportData = useMemo( () => flattenFolders(reportOriginData), [reportOriginData], ); useEffect(() => { fileMetadataCacheRef.current.clear(); fileMetadataPromiseCacheRef.current.clear(); }, [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(() => { void fetchDcTree("기본계획"); }, [fetchDcTree]); useEffect(() => { hasAutoFetchedRef.current = false; setCtpvCd("0"); setSggCd("0"); setPlantZoneGubun("0"); setReportGubun("0"); setYearFilter({ start: "", end: "" }); setShowTable(false); setTableData([]); setTableCount(0); setCurPage(1); }, [open, setCurPage, setTableCount, setTableData, setYearFilter]); useEffect(() => { const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || "0"; const newSggCd = sgg.find((n) => n.text === sggNm)?.val || "0"; setCtpvCd(newCtpvCd); setSggCd(newSggCd); }, [ctpv, ctpvNm, sgg, sggNm]); useEffect(() => { if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current) return; hasAutoFetchedRef.current = true; void fetchFilteredData(1); }, [flattenedReportData.length, open]); useEffect(() => { if (showTable) { void fetchFilteredData(); } }, [curPage]); const handleCtpvCdChange = (sidoId: string) => { setCtpvCd(sidoId); setSggCd("0"); }; const handleSearch = () => { hasAutoFetchedRef.current = true; setCurPage(1); void fetchFilteredData(1); setShowTable(true); }; /** 보고서 조건·페이지·결과를 기본값으로 복원하고 전체 목록을 즉시 재조회한다. */ const handleResetSearch = () => { setCtpvCd(DEFAULT_REPORT_SEARCH_FILTERS.ctpvCd); setSggCd(DEFAULT_REPORT_SEARCH_FILTERS.sggCd); setPlantZoneGubun(DEFAULT_REPORT_SEARCH_FILTERS.plantZoneGubun); setReportGubun(DEFAULT_REPORT_SEARCH_FILTERS.reportGubun); setYearFilter({ ...DEFAULT_REPORT_SEARCH_FILTERS.yearFilter }); setCurPage(1); setTableData([]); setTableCount(0); setShowTable(false); hasAutoFetchedRef.current = true; void fetchFilteredData(1, DEFAULT_REPORT_SEARCH_FILTERS); }; const handleReportCellClick = (index: number | undefined, headId: string) => { if (headId !== "fileName") return; if (typeof index !== "number" || !tableData[index]) return; const reportData = tableData[index]; if (!reportData.fileSn) return; void (async () => { setIsLoading(true); try { await downloadReportFile(reportData); } finally { setIsLoading(false); } })(); }; const buildReportMetadata = ( fileArr: ReportDataFile[], ): ReportFileMetadata => { if (fileArr.length === 0) { return EMPTY_REPORT_METADATA; } const primaryFile = fileArr[0]; return { fileName: fileArr.length === 1 ? (primaryFile?.fileTitle ?? "-") : `${primaryFile?.fileTitle ?? "대표 파일"} 외 ${fileArr.length - 1}건`, regDate: primaryFile?.regDt ?? "", fileSn: primaryFile?.fileSn ?? "", filePath: primaryFile?.filePath ?? "", fileCount: fileArr.length, downloadFileTitle: primaryFile?.fileTitle ?? "", }; }; const resolveReportMetadata = (item: FlattenedFolder) => { const key = String(item.clsfSystmSn ?? ""); if (!key) { return Promise.resolve(EMPTY_REPORT_METADATA); } const cached = fileMetadataCacheRef.current.get(key); if (cached) { return Promise.resolve(cached); } const pending = fileMetadataPromiseCacheRef.current.get(key); if (pending) { return pending; } const promise = Promise.race([ getDcFiles(key), new Promise((_, reject) => { window.setTimeout( () => reject(new Error(`Report metadata timeout: ${key}`)), REPORT_METADATA_TIMEOUT_MS, ); }), ]) .then((fileArr) => buildReportMetadata(fileArr)) .catch((error) => { console.warn("보고서 파일 메타데이터 조회 실패:", key, error); return EMPTY_REPORT_METADATA; }) .then((metadata) => { fileMetadataCacheRef.current.set(key, metadata); fileMetadataPromiseCacheRef.current.delete(key); return metadata; }); fileMetadataPromiseCacheRef.current.set(key, promise); return promise; }; const enrichReportRows = async ( data: FlattenedFolder[], concurrency = REPORT_METADATA_CONCURRENCY, ) => { const missingRows = data.filter((item) => { const key = String(item.clsfSystmSn ?? ""); return key && !fileMetadataCacheRef.current.has(key); }); if (missingRows.length > 0) { let cursor = 0; const workerCount = Math.min(concurrency, missingRows.length); await Promise.all( Array.from({ length: workerCount }, async () => { while (cursor < missingRows.length) { const currentIndex = cursor; cursor += 1; const currentRow = missingRows[currentIndex]; if (currentRow) { await resolveReportMetadata(currentRow); } } }), ); } return data.map((item) => ({ ...item, ...(fileMetadataCacheRef.current.get(String(item.clsfSystmSn ?? "")) ?? EMPTY_REPORT_METADATA), })); }; const fetchFilteredData = async ( page = curPage, filters: ReportSearchFilters = { ctpvCd, sggCd, plantZoneGubun, reportGubun, yearFilter, }, ) => { setIsLoading(true); try { let filterReportData = flattenedReportData; if (filters.ctpvCd !== "0") { const sidoOption = ctpv.find( (n) => String(n.val).trim() === filters.ctpvCd.trim(), ); filterReportData = filterReportData.filter( (item) => item.sido === sidoOption?.text, ); } if (filters.sggCd !== "0") { const sigunguOption = sgg.find( (n) => String(n.val) === String(filters.sggCd), ); filterReportData = filterReportData.filter( (item) => item.sigungu === sigunguOption?.text, ); } if (filters.plantZoneGubun !== "0") { filterReportData = filterReportData.filter( (item) => String(item.plantZoneGubun ?? "") === filters.plantZoneGubun, ); } if (filters.reportGubun !== "0") { const reportGubunOption = REPORT_TYPES.find( (n) => n.val === filters.reportGubun, ); filterReportData = filterReportData.filter( (item) => item.reportGubun === reportGubunOption?.text, ); } const normalizedStartYear = normalizeYear(filters.yearFilter.start); const normalizedEndYear = normalizeYear(filters.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; }); } const totalCount = filterReportData.length || 0; setTableCount(totalCount); const pageOffset = (page - 1) * REPORT_PAGE_SIZE; const pageStart = (page - 1) * REPORT_PAGE_SIZE; const pageEnd = page * REPORT_PAGE_SIZE; let reportRows: FlattenedFolder[]; const pagedReportData = filterReportData .slice(pageStart, pageEnd) .map((item, index) => ({ ...item, no: totalCount - (pageOffset + index), })); reportRows = await enrichReportRows(pagedReportData); setTableData(reportRows || []); setShowTable(true); } catch (err) { console.error("API ERROR:", err); setTableData([]); } finally { setIsLoading(false); } }; return (
{isSearch && ( { handleCtpvCdChange(sidoId); }} onSigunguChange={setSggCd} setIsLoading={setIsLoading} /> )} setYearFilter({ start: normalizeYear(range.start), end: normalizeYear(range.end), }) } startLabel="승인년도" endLabel="" />
setPlantZoneGubun(String(val))} selectContentClass="z-[101]" />
setReportGubun(String(val))} selectContentClass="z-[101]" />
>} clickableHeadIds={["fileName"]} title="보고서 목록" headlist={reportTableHeadlist} pageSize={10} total={tableCount} onCellClick={(_row, index, headId) => handleReportCellClick(index, headId) } onPageChange={setCurPage} page={curPage} rowId={(row, index) => String(row.clsfSystmSn ?? index)} />
); }; export default React.memo(ReportTab);