484 lines
16 KiB
TypeScript
484 lines
16 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/YearRangeSelectBox';
|
|
import { SelectBox } from "@common/business_components/ui/select";
|
|
import { UiTable } from "@common/business_components/ui/table";
|
|
import LoadingOverlay from '@/components/ui/LoadingBar';
|
|
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 { UiTableSort } from "@common/business_components/ui/table";
|
|
import type { FlattenedFolder, ReportDataFile } from './useReportTab';
|
|
|
|
// 하수도정비 기본계획 보고서
|
|
export const reportTableHeadlist = [
|
|
{ id: 'no', title: 'NO', sortable: true },
|
|
{ id: 'sido', title: '시도', sortable: true },
|
|
{ id: 'sigungu', title: '시군구', sortable: true },
|
|
{ id: 'approvalYear', title: '승인년도', sortable: true },
|
|
{ id: 'plantZoneGubun', title: '구분', sortable: true },
|
|
{ id: 'reportGubun', title: '보고서구분', sortable: true },
|
|
{ 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_COLLATOR = new Intl.Collator('ko', { numeric: true, sensitivity: 'base' });
|
|
const REPORT_PAGE_SIZE = 10;
|
|
const REPORT_METADATA_CONCURRENCY = 8;
|
|
const REPORT_METADATA_TIMEOUT_MS = 8000;
|
|
const DISABLED_REPORT_SORT_KEYS = new Set(['fileName', 'regDate']);
|
|
|
|
type ReportFileMetadata = Pick<FlattenedFolder,
|
|
'fileName' | 'regDate' | 'fileSn' | 'filePath' | 'fileCount' | 'downloadFileTitle'>;
|
|
|
|
const EMPTY_REPORT_METADATA: ReportFileMetadata = {
|
|
fileName: '-',
|
|
regDate: '',
|
|
fileSn: '',
|
|
filePath: '',
|
|
fileCount: 0,
|
|
downloadFileTitle: '',
|
|
};
|
|
|
|
// 파일 정보 추가 조회 전에도 존재하는 분류 트리 필드만 전체 데이터 기준으로 정렬한다.
|
|
const compareReportRows = (left: FlattenedFolder, right: FlattenedFolder, sort: UiTableSort) => {
|
|
const leftValue = left[sort.key];
|
|
const rightValue = right[sort.key];
|
|
const leftEmpty = leftValue === null || leftValue === undefined || String(leftValue).trim() === '';
|
|
const rightEmpty = rightValue === null || rightValue === undefined || String(rightValue).trim() === '';
|
|
|
|
if (leftEmpty || rightEmpty) {
|
|
if (leftEmpty && rightEmpty) return Number(left.no ?? 0) - Number(right.no ?? 0);
|
|
return leftEmpty ? 1 : -1;
|
|
}
|
|
|
|
const comparison = sort.key === 'approvalYear'
|
|
? Number(getApprovalYearNumber(leftValue) ?? 0) - Number(getApprovalYearNumber(rightValue) ?? 0)
|
|
: REPORT_COLLATOR.compare(String(leftValue), String(rightValue));
|
|
if (comparison === 0) return Number(left.no ?? 0) - Number(right.no ?? 0);
|
|
return sort.direction === 'asc' ? comparison : -comparison;
|
|
};
|
|
|
|
const resolveReportSort = (sort?: UiTableSort) => {
|
|
if (!sort) return undefined;
|
|
if (DISABLED_REPORT_SORT_KEYS.has(sort.key)) {
|
|
return undefined;
|
|
}
|
|
return sort;
|
|
};
|
|
|
|
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 [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,
|
|
sort,
|
|
setSort,
|
|
} = 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);
|
|
setSort(undefined);
|
|
setIsResetPage(true);
|
|
setIsResetSelection(true);
|
|
}, [open, setCurPage, setSort, 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(() => {
|
|
setIsResetPage(false);
|
|
|
|
if (showTable) {
|
|
void fetchFilteredData();
|
|
}
|
|
}, [curPage]);
|
|
|
|
const handleCtpvCdChange = (sidoId: string) => {
|
|
setCtpvCd(sidoId);
|
|
setSggCd('0');
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
hasAutoFetchedRef.current = true;
|
|
setCurPage(1);
|
|
void fetchFilteredData(1);
|
|
setIsResetPage(true);
|
|
setIsResetSelection(false);
|
|
setShowTable(true);
|
|
};
|
|
|
|
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, requestedSort = sort) => {
|
|
setIsLoading(true);
|
|
setIsResetSelection(true);
|
|
|
|
try {
|
|
const activeSort = resolveReportSort(requestedSort);
|
|
let filterReportData = flattenedReportData;
|
|
|
|
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') {
|
|
const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd));
|
|
filterReportData = filterReportData.filter((item) => item.sigungu === sigunguOption?.text);
|
|
}
|
|
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);
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
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 sortedReportData = activeSort
|
|
? [...filterReportData].sort((left, right) => compareReportRows(left, right, activeSort))
|
|
: filterReportData;
|
|
|
|
const pagedReportData = sortedReportData
|
|
.slice(pageStart, pageEnd)
|
|
.map((item, index) => ({
|
|
...item,
|
|
no: activeSort?.key === 'no' && activeSort.direction === 'desc'
|
|
? sortedReportData.length - (pageOffset + index)
|
|
: pageOffset + index + 1,
|
|
}));
|
|
|
|
reportRows = await enrichReportRows(pagedReportData);
|
|
|
|
setTableData(reportRows || []);
|
|
setShowTable(true);
|
|
setIsResetSelection(false);
|
|
} catch (err) {
|
|
console.error('API ERROR:', err);
|
|
setTableData([]);
|
|
setIsResetSelection(false);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// 보고서 분류 원본 전체를 다시 정렬한 뒤 첫 페이지를 표시한다.
|
|
const handleSortChange = (nextSort?: UiTableSort) => {
|
|
setSort(nextSort);
|
|
setIsResetPage(true);
|
|
setIsResetSelection(true);
|
|
if (curPage === 1) {
|
|
void fetchFilteredData(1, nextSort);
|
|
return;
|
|
}
|
|
setCurPage(1);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<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); }}
|
|
onSigunguChange={setSggCd}
|
|
setIsLoading={setIsLoading}
|
|
/>
|
|
)}
|
|
<YearRangeSelectBox
|
|
className="shrink-0 !mr-1"
|
|
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=""
|
|
/>
|
|
<Label className="mr-2 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]"
|
|
/>
|
|
<Label className="mr-2 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 className="ml-auto shrink-0 pl-4">
|
|
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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}
|
|
curPage={curPage}
|
|
sort={sort}
|
|
onSortChange={handleSortChange}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default React.memo(ReportTab);
|
|
|
|
|
|
|
|
|