Files
frontend/apps/sn/app/(map-layout)/map/search-modal/report-tab.tsx
T

511 lines
15 KiB
TypeScript

/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭
*
* 📄 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/year-range-select-box";
import { SelectBox } from "@common/business_components/ui/select";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import useReportTab from "./use-report-tab";
import useStore from "@/entities/reference-data/model/reference-data-store";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import useSearchModalStore from "@/features/map-search/model/search-store";
import { useReportTabStore } from "@/features/map-search/model/report-store";
import type { FlattenedFolder, ReportDataFile } from "@/features/map-search/model/report-types";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
// 하수도정비 기본계획 보고서
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<ReportTabProps> = ({
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<Map<string, ReportFileMetadata>>(
new Map(),
);
const fileMetadataPromiseCacheRef = useRef<
Map<string, Promise<ReportFileMetadata>>
>(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<ReportDataFile[] | never>([
getDcFiles(key),
new Promise<never>((_, 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: pageOffset + index + 1,
}));
reportRows = await enrichReportRows(pagedReportData);
setTableData(reportRows || []);
setShowTable(true);
} catch (err) {
console.error("API ERROR:", err);
setTableData([]);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-w-0 flex-col gap-3">
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
{isSearch && (
<CtpvSggPrcsFcltSelectBox
className="shrink-0"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => {
handleCtpvCdChange(sidoId);
}}
onSigunguChange={setSggCd}
setIsLoading={setIsLoading}
/>
)}
<YearRangeSelectBox
className="!mr-1 shrink-0"
selectClass="w-[78px] flex-shrink-0 rounded-lg bg-field text-foreground"
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) =>
setYearFilter({
start: normalizeYear(range.start),
end: normalizeYear(range.end),
})
}
startLabel="승인년도"
endLabel=""
/>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
구분
</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={plantZoneOptions}
value={plantZoneGubun}
onSelectValue={(val) => setPlantZoneGubun(String(val))}
selectContentClass="z-[101]"
/>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-label">
보고서 구분
</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun}
onSelectValue={(val) => setReportGubun(String(val))}
selectContentClass="z-[101]"
/>
</div>
<div className="shrink-0 sm:ml-auto sm:pl-4">
<div className="flex gap-2">
<Button onClick={handleSearch}>조회</Button>
<Button color="white" onClick={handleResetSearch}>
초기화
</Button>
</div>
</div>
</PopupSearchPanel>
<div>
<MapPopupDataTable
rows={tableData as Array<FlattenedFolder & Record<string, unknown>>}
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)}
/>
</div>
</div>
);
};
export default React.memo(ReportTab);