511 lines
15 KiB
TypeScript
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);
|