그리드 공통 정렬 기능 확대 적용

This commit is contained in:
kiminchan
2026-07-20 14:56:23 +09:00
parent d9febf8010
commit d17909df41
30 changed files with 1551 additions and 264 deletions
@@ -23,15 +23,17 @@ 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';
import type { FlattenedFolder, ReportDataFile } from './useReportTab';
// 하수도정비 기본계획 보고서
export const reportTableHeadlist = [
{ id: 'no', title: 'NO' },
{ id: 'sido', title: '시도' },
{ id: 'sigungu', title: '시군구' },
{ id: 'approvalYear', title: '승인년도' },
{ id: 'plantZoneGubun', title: '구분' },
{ id: 'reportGubun', title: '보고서구분' },
{ 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: '등록일자' },
];
@@ -53,6 +55,51 @@ const getApprovalYearNumber = (value: string | number | undefined) => {
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);
@@ -62,6 +109,8 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
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,
@@ -73,16 +122,23 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
setTableCount,
setIsLoading,
setYearFilter,
setCurPage
setCurPage,
sort,
setSort,
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const {
dcTree: reportOriginData,
fetchDcTree
} = useReportTabStore();
const { REPORT_TYPES, flattenFolders, addFileName, downloadReportFile } = useReportTab();
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(
@@ -113,9 +169,10 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
setTableData([]);
setTableCount(0);
setCurPage(1);
setSort(undefined);
setIsResetPage(true);
setIsResetSelection(true);
}, [open, setCurPage, setTableCount, setTableData, setYearFilter]);
}, [open, setCurPage, setSort, setTableCount, setTableData, setYearFilter]);
useEffect(() => {
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0';
@@ -171,11 +228,94 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
})();
};
const fetchFilteredData = async (page = curPage) => {
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') {
@@ -206,13 +346,29 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
});
}
setTableCount(filterReportData.length || 0);
const totalCount = filterReportData.length || 0;
setTableCount(totalCount);
const pagedReportData = filterReportData
.slice((page - 1) * 10, page * 10)
.map((item, index) => ({ ...item, no: (page - 1) * 10 + index + 1 }));
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);
const reportRows = await addFileName(pagedReportData);
setTableData(reportRows || []);
setShowTable(true);
setIsResetSelection(false);
@@ -225,6 +381,18 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
}
};
// 보고서 분류 원본 전체를 다시 정렬한 뒤 첫 페이지를 표시한다.
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">
@@ -298,6 +466,9 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
curPage={curPage}
sort={sort}
onSortChange={handleSortChange}
/>
</div>
</>