- 회계: 매출/청구/입출금 관리 UI 개선 - 급여: SalaryDetailDialog 대폭 개선, SalaryRegistrationDialog 신규 - 공통: IntegratedDetailTemplate, UniversalListPage 보강 - UI: currency-input 컴포넌트 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1118 lines
42 KiB
TypeScript
1118 lines
42 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* UniversalListPage - 통합 리스트 페이지 컴포넌트
|
|
*
|
|
* 59개 리스트 페이지를 하나의 config 기반 컴포넌트로 통합
|
|
* 기존 기능 100% 유지, 테이블 영역만 공통화
|
|
*
|
|
* 지원 모드:
|
|
* - 서버 사이드 필터링/페이지네이션 (기본)
|
|
* - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true)
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { useRouter, useParams } from 'next/navigation';
|
|
import { usePermission } from '@/hooks/usePermission';
|
|
import { useColumnSettings } from '@/hooks/useColumnSettings';
|
|
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
|
import { toast } from 'sonner';
|
|
import { Download, Loader2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import {
|
|
IntegratedListTemplateV2,
|
|
type PaginationConfig,
|
|
} from '@/components/templates/IntegratedListTemplateV2';
|
|
import { downloadExcel, downloadSelectedExcel, type ExcelColumn } from '@/lib/utils/excel-download';
|
|
import type {
|
|
UniversalListPageProps,
|
|
TabOption,
|
|
FilterValues,
|
|
} from './types';
|
|
import { NON_SORTABLE_KEYS } from './types';
|
|
|
|
export function UniversalListPage<T>({
|
|
config,
|
|
initialData,
|
|
initialTotalCount,
|
|
externalPagination,
|
|
externalSelection,
|
|
onTabChange,
|
|
onSearchChange,
|
|
onFilterChange: onFilterChangeCallback,
|
|
externalIsLoading,
|
|
}: UniversalListPageProps<T>) {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const locale = (params.locale as string) || 'ko';
|
|
const { canCreate: permCanCreate, canDelete: permCanDelete, canExport } = usePermission();
|
|
|
|
// ===== 상태 관리 =====
|
|
// 원본 데이터 (클라이언트 사이드 필터링용)
|
|
const [rawData, setRawData] = useState<T[]>(initialData || []);
|
|
// UI 상태
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(!initialData);
|
|
const [searchValue, setSearchValue] = useState(''); // UI 입력용 (즉시 반영)
|
|
const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); // API 호출용 (debounced)
|
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
|
// 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함
|
|
const [activeTab, setActiveTab] = useState(
|
|
config.defaultTab || config.tabs?.[0]?.value || 'default'
|
|
);
|
|
const [filters, setFilters] = useState<Record<string, string | string[]>>(
|
|
config.initialFilters || {}
|
|
);
|
|
const [tabs, setTabs] = useState<TabOption[]>(config.tabs || []);
|
|
|
|
// 정렬 상태
|
|
const [sortBy, setSortBy] = useState<string | undefined>(undefined);
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
|
|
|
// 모달 상태 (detailMode === 'modal'일 때 사용)
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [selectedItem, setSelectedItem] = useState<T | null>(null);
|
|
|
|
// 삭제 다이얼로그 상태
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [itemToDelete, setItemToDelete] = useState<T | null>(null);
|
|
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
|
|
|
// 엑셀 다운로드 상태
|
|
const [isExcelDownloading, setIsExcelDownloading] = useState(false);
|
|
|
|
const itemsPerPage = config.itemsPerPage || 20;
|
|
|
|
// 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시)
|
|
const [isMobileLoading, setIsMobileLoading] = useState(false);
|
|
|
|
// 초기 데이터 로딩 완료 여부 (검색/필터 변경 시 전체 스켈레톤 방지)
|
|
const isInitialFetchDone = useRef(false);
|
|
|
|
// 서버 사이드 페이지네이션 상태 (API에서 반환하는 값)
|
|
const [serverTotalCount, setServerTotalCount] = useState<number>(initialTotalCount || 0);
|
|
const [serverTotalPages, setServerTotalPages] = useState<number>(1);
|
|
|
|
// ===== 검색 Debounce (300ms) =====
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setDebouncedSearchValue(searchValue);
|
|
// 검색 변경 시 페이지를 1로 리셋 (서버 사이드 페이지네이션에서 올바른 결과 보장)
|
|
setCurrentPage(1);
|
|
}, 300);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [searchValue]);
|
|
|
|
// ===== ID 추출 헬퍼 =====
|
|
const getItemId = useCallback(
|
|
(item: T): string => {
|
|
if (typeof config.idField === 'function') {
|
|
return config.idField(item);
|
|
}
|
|
return String(item[config.idField]);
|
|
},
|
|
[config.idField]
|
|
);
|
|
|
|
// ===== 데이터 필터링 =====
|
|
// 서버 사이드 모드에서 searchFilter를 통한 클라이언트 사이드 검색 활성화 여부
|
|
const isServerSearchFiltered = !config.clientSideFiltering && !!debouncedSearchValue && !!config.searchFilter;
|
|
|
|
const filteredData = useMemo(() => {
|
|
if (!config.clientSideFiltering) {
|
|
// 서버 사이드 모드
|
|
let serverData = rawData;
|
|
|
|
// searchFilter가 정의되어 있으면 클라이언트 사이드 검색 적용 (백엔드 검색 미지원 대비)
|
|
if (debouncedSearchValue && config.searchFilter) {
|
|
serverData = rawData.filter((item) =>
|
|
config.searchFilter!(item, debouncedSearchValue)
|
|
);
|
|
}
|
|
|
|
// 서버 사이드에서도 컬럼 정렬 지원 (onSortChange 미정의 시 클라이언트 사이드 정렬)
|
|
if (sortBy && !config.onSortChange) {
|
|
serverData = [...serverData].sort((a, b) => {
|
|
const aValue = (a as Record<string, unknown>)[sortBy];
|
|
const bValue = (b as Record<string, unknown>)[sortBy];
|
|
if (aValue == null && bValue == null) return 0;
|
|
if (aValue == null) return sortOrder === 'asc' ? 1 : -1;
|
|
if (bValue == null) return sortOrder === 'asc' ? -1 : 1;
|
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
|
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
|
|
}
|
|
const aStr = String(aValue);
|
|
const bStr = String(bValue);
|
|
const comparison = aStr.localeCompare(bStr, 'ko');
|
|
return sortOrder === 'asc' ? comparison : -comparison;
|
|
});
|
|
}
|
|
|
|
return serverData;
|
|
}
|
|
|
|
let filtered = [...rawData];
|
|
|
|
// 커스텀 필터 함수 (filterConfig 기반 복잡한 필터링)
|
|
if (config.customFilterFn) {
|
|
filtered = config.customFilterFn(filtered, filters);
|
|
}
|
|
|
|
// 탭 필터
|
|
if (activeTab !== 'all' && config.tabFilter) {
|
|
filtered = filtered.filter((item) => config.tabFilter!(item, activeTab));
|
|
}
|
|
|
|
// 검색 필터 (debounced 값 사용)
|
|
if (debouncedSearchValue && config.searchFilter) {
|
|
filtered = filtered.filter((item) =>
|
|
config.searchFilter!(item, debouncedSearchValue)
|
|
);
|
|
}
|
|
|
|
// 날짜 범위 필터 (dateRangeSelector.dateField 설정 시 자동 적용)
|
|
const { dateRangeSelector } = config;
|
|
if (dateRangeSelector?.enabled && dateRangeSelector.dateField && dateRangeSelector.startDate && dateRangeSelector.endDate) {
|
|
const dateField = dateRangeSelector.dateField;
|
|
const start = new Date(dateRangeSelector.startDate);
|
|
const end = new Date(dateRangeSelector.endDate);
|
|
end.setHours(23, 59, 59, 999); // 종료일 끝까지 포함
|
|
|
|
filtered = filtered.filter((item) => {
|
|
const itemDate = (item as Record<string, unknown>)[dateField];
|
|
if (!itemDate) return false;
|
|
const date = new Date(String(itemDate));
|
|
return date >= start && date <= end;
|
|
});
|
|
}
|
|
|
|
// 커스텀 정렬 함수
|
|
if (config.customSortFn) {
|
|
filtered = config.customSortFn(filtered, filters);
|
|
}
|
|
|
|
// 컬럼 기반 정렬 (sortBy가 있을 때)
|
|
if (sortBy) {
|
|
filtered = [...filtered].sort((a, b) => {
|
|
const aValue = (a as Record<string, unknown>)[sortBy];
|
|
const bValue = (b as Record<string, unknown>)[sortBy];
|
|
|
|
// null/undefined 처리
|
|
if (aValue == null && bValue == null) return 0;
|
|
if (aValue == null) return sortOrder === 'asc' ? 1 : -1;
|
|
if (bValue == null) return sortOrder === 'asc' ? -1 : 1;
|
|
|
|
// 숫자 비교
|
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
|
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
|
|
}
|
|
|
|
// 문자열 비교 (한글 지원)
|
|
const aStr = String(aValue);
|
|
const bStr = String(bValue);
|
|
const comparison = aStr.localeCompare(bStr, 'ko');
|
|
return sortOrder === 'asc' ? comparison : -comparison;
|
|
});
|
|
}
|
|
|
|
return filtered;
|
|
}, [rawData, activeTab, debouncedSearchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.onSortChange, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn, config.dateRangeSelector]);
|
|
|
|
// 페이지네이션 (클라이언트 사이드 + 서버 사이드 검색 시)
|
|
const paginatedData = useMemo(() => {
|
|
if (!config.clientSideFiltering) {
|
|
// 서버 사이드 검색 시 클라이언트 사이드 페이지네이션 적용
|
|
if (debouncedSearchValue && config.searchFilter) {
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
return filteredData.slice(startIndex, startIndex + itemsPerPage);
|
|
}
|
|
return rawData;
|
|
}
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
return filteredData.slice(startIndex, startIndex + itemsPerPage);
|
|
}, [config.clientSideFiltering, config.searchFilter, debouncedSearchValue, filteredData, currentPage, itemsPerPage, rawData]);
|
|
|
|
// 총 개수 및 페이지 수
|
|
// 서버 사이드 페이지네이션: API에서 반환한 값 사용
|
|
// 클라이언트 사이드 페이지네이션: 로컬 데이터 길이 사용
|
|
const totalCount = config.clientSideFiltering
|
|
? filteredData.length
|
|
: (isServerSearchFiltered ? filteredData.length : serverTotalCount);
|
|
const totalPages = config.clientSideFiltering
|
|
? Math.ceil(totalCount / itemsPerPage)
|
|
: (isServerSearchFiltered ? (Math.ceil(filteredData.length / itemsPerPage) || 1) : serverTotalPages);
|
|
|
|
// 삭제 등으로 현재 페이지가 빈 페이지가 되면 마지막 유효 페이지로 이동
|
|
useEffect(() => {
|
|
if (currentPage > 1 && (totalPages === 0 || currentPage > totalPages)) {
|
|
setCurrentPage(Math.max(1, totalPages));
|
|
}
|
|
}, [totalPages, currentPage]);
|
|
|
|
// 표시할 데이터
|
|
// 서버 사이드 모드에서도 filteredData 사용 (클라이언트 사이드 정렬 반영)
|
|
const displayData = (config.clientSideFiltering || isServerSearchFiltered) ? paginatedData : filteredData;
|
|
|
|
// ===== 탭 카운트 계산 (클라이언트 사이드) =====
|
|
const computedTabs = useMemo(() => {
|
|
if (!config.clientSideFiltering || !config.tabs || !config.tabFilter) {
|
|
return tabs;
|
|
}
|
|
|
|
return config.tabs.map((tab) => {
|
|
if (tab.value === 'all') {
|
|
return { ...tab, count: rawData.length };
|
|
}
|
|
const count = rawData.filter((item) => config.tabFilter!(item, tab.value)).length;
|
|
return { ...tab, count };
|
|
});
|
|
}, [config.clientSideFiltering, config.tabs, config.tabFilter, rawData, tabs]);
|
|
|
|
// ===== 데이터 로딩 =====
|
|
// isMobileAppend: 모바일 인피니티 스크롤로 추가 로드 시 true
|
|
const fetchData = useCallback(async (isMobileAppend = false) => {
|
|
// 모바일 추가 로드면 isMobileLoading, 그 외에는 isLoading
|
|
if (isMobileAppend) {
|
|
setIsMobileLoading(true);
|
|
} else if (!isInitialFetchDone.current) {
|
|
// 초기 로딩 시에만 전체 스켈레톤 표시
|
|
setIsLoading(true);
|
|
}
|
|
|
|
try {
|
|
// 서버 사이드 + searchFilter 정의 + 검색 중: 전체 데이터를 받아서 클라이언트 사이드 필터링
|
|
const useClientSearch = !config.clientSideFiltering && !!config.searchFilter && !!debouncedSearchValue;
|
|
const result = await config.actions.getList(
|
|
config.clientSideFiltering
|
|
? { pageSize: 9999 } // 클라이언트 사이드: 전체 데이터 로드
|
|
: {
|
|
page: useClientSearch ? 1 : currentPage,
|
|
pageSize: useClientSearch ? 9999 : itemsPerPage,
|
|
search: useClientSearch ? undefined : debouncedSearchValue,
|
|
filters,
|
|
tab: activeTab,
|
|
}
|
|
);
|
|
|
|
if (result.success && result.data) {
|
|
setRawData(result.data);
|
|
// 서버 사이드 페이지네이션: API에서 반환한 totalCount, totalPages 저장
|
|
if (!config.clientSideFiltering) {
|
|
if (typeof result.totalCount === 'number') {
|
|
setServerTotalCount(result.totalCount);
|
|
}
|
|
if (typeof result.totalPages === 'number') {
|
|
setServerTotalPages(result.totalPages);
|
|
}
|
|
}
|
|
} else {
|
|
toast.error(result.error || '데이터를 불러오는데 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('[UniversalListPage] Fetch error:', error);
|
|
toast.error('데이터를 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsMobileLoading(false);
|
|
isInitialFetchDone.current = true;
|
|
}
|
|
}, [config.actions, config.clientSideFiltering, config.searchFilter, currentPage, itemsPerPage, debouncedSearchValue, filters, activeTab]);
|
|
|
|
// 초기 로딩 (initialData가 없거나 빈 배열인 경우)
|
|
useEffect(() => {
|
|
if (!initialData || initialData.length === 0) {
|
|
fetchData();
|
|
}
|
|
}, []);
|
|
|
|
// initialData prop 변경 감지 (부모 컴포넌트에서 데이터 로드 후 전달하는 경우)
|
|
// 삭제 후 빈 배열도 동기화해야 빈 페이지가 올바르게 표시됨
|
|
useEffect(() => {
|
|
if (initialData) {
|
|
setRawData(initialData);
|
|
}
|
|
}, [initialData]);
|
|
|
|
// config.tabs 변경 감지 (동적 탭 카운트 업데이트용)
|
|
useEffect(() => {
|
|
if (config.tabs) {
|
|
setTabs(config.tabs);
|
|
}
|
|
}, [config.tabs]);
|
|
|
|
// 선택 항목 변경 시 외부 콜백 호출
|
|
useEffect(() => {
|
|
if (config.onSelectionChange && !externalSelection) {
|
|
config.onSelectionChange(selectedItems);
|
|
}
|
|
}, [selectedItems, config.onSelectionChange, externalSelection]);
|
|
|
|
// 데이터 변경 콜백 (동적 컬럼 계산 등에 사용)
|
|
// ⚠️ config.onDataChange를 deps에서 제외: 콜백 참조 변경으로 인한 무한 루프 방지
|
|
useEffect(() => {
|
|
config.onDataChange?.(rawData);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [rawData]);
|
|
|
|
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
|
|
// 이전 페이지를 추적하여 모바일 인피니티 스크롤 감지
|
|
const [prevPage, setPrevPage] = useState(1);
|
|
|
|
// 날짜 범위 변경 감지용 (서버 사이드 필터링에서 날짜 변경 시 데이터 새로고침)
|
|
const dateRangeKey = config.dateRangeSelector?.enabled
|
|
? `${config.dateRangeSelector.startDate || ''}-${config.dateRangeSelector.endDate || ''}`
|
|
: '';
|
|
|
|
useEffect(() => {
|
|
if (!config.clientSideFiltering && !externalPagination && !isLoading && !isMobileLoading) {
|
|
// 페이지가 증가하는 경우 = 모바일 인피니티 스크롤
|
|
const isMobileAppend = currentPage > prevPage && currentPage > 1;
|
|
fetchData(isMobileAppend);
|
|
setPrevPage(currentPage);
|
|
}
|
|
}, [currentPage, debouncedSearchValue, filters, activeTab, dateRangeKey]);
|
|
|
|
// 동적 탭 로딩
|
|
useEffect(() => {
|
|
if (config.fetchTabs) {
|
|
config.fetchTabs().then((fetchedTabs) => {
|
|
setTabs(fetchedTabs);
|
|
if (!activeTab || activeTab === 'all') {
|
|
setActiveTab(fetchedTabs[0]?.value || 'all');
|
|
}
|
|
});
|
|
}
|
|
}, [config.fetchTabs]);
|
|
|
|
// ===== 선택 핸들러 =====
|
|
// 외부 선택 상태 사용 시 외부 핸들러 사용
|
|
const effectiveSelectedItems = externalSelection?.selectedItems ?? selectedItems;
|
|
const effectiveGetItemId = externalSelection?.getItemId ?? getItemId;
|
|
|
|
const toggleSelection = useCallback(
|
|
(id: string) => {
|
|
if (externalSelection) {
|
|
externalSelection.onToggleSelection(id);
|
|
} else {
|
|
setSelectedItems((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(id)) {
|
|
newSet.delete(id);
|
|
} else {
|
|
newSet.add(id);
|
|
}
|
|
return newSet;
|
|
});
|
|
}
|
|
},
|
|
[externalSelection]
|
|
);
|
|
|
|
const toggleSelectAll = useCallback(() => {
|
|
if (externalSelection) {
|
|
externalSelection.onToggleSelectAll();
|
|
} else {
|
|
const currentData = displayData;
|
|
if (selectedItems.size === currentData.length && currentData.length > 0) {
|
|
setSelectedItems(new Set());
|
|
} else {
|
|
const allIds = new Set(currentData.map((item) => getItemId(item)));
|
|
setSelectedItems(allIds);
|
|
}
|
|
}
|
|
}, [externalSelection, displayData, selectedItems.size, getItemId]);
|
|
|
|
// ===== 행 클릭 핸들러 =====
|
|
const handleRowClick = useCallback(
|
|
(item: T) => {
|
|
const id = getItemId(item);
|
|
const detailMode = config.detailMode || 'page';
|
|
|
|
if (detailMode === 'modal') {
|
|
setSelectedItem(item);
|
|
setIsModalOpen(true);
|
|
} else if (detailMode === 'page') {
|
|
router.push(`/${locale}${config.basePath}/${id}?mode=view`);
|
|
}
|
|
},
|
|
[config.basePath, config.detailMode, getItemId, locale, router]
|
|
);
|
|
|
|
const handleEdit = useCallback(
|
|
(item: T) => {
|
|
const id = getItemId(item);
|
|
router.push(`/${locale}${config.basePath}/${id}?mode=edit`);
|
|
},
|
|
[config.basePath, getItemId, locale, router]
|
|
);
|
|
|
|
const handleCreate = useCallback(() => {
|
|
router.push(`/${locale}${config.basePath}?mode=new`);
|
|
}, [config.basePath, locale, router]);
|
|
|
|
// ===== 삭제 핸들러 =====
|
|
const handleDeleteClick = useCallback((item: T) => {
|
|
setItemToDelete(item);
|
|
setIsBulkDelete(false);
|
|
setDeleteDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleBulkDeleteClick = useCallback(() => {
|
|
if (effectiveSelectedItems.size === 0) {
|
|
toast.warning('삭제할 항목을 선택해주세요.');
|
|
return;
|
|
}
|
|
setIsBulkDelete(true);
|
|
setDeleteDialogOpen(true);
|
|
}, [effectiveSelectedItems.size]);
|
|
|
|
const handleDeleteConfirm = useCallback(async () => {
|
|
try {
|
|
if (isBulkDelete) {
|
|
if (config.actions.deleteBulk) {
|
|
const result = await config.actions.deleteBulk(Array.from(effectiveSelectedItems));
|
|
if (result.success) {
|
|
toast.success(`${effectiveSelectedItems.size}건이 삭제되었습니다.`);
|
|
// 클라이언트 사이드: 로컬 데이터에서 제거
|
|
if (config.clientSideFiltering) {
|
|
setRawData((prev) =>
|
|
prev.filter((item) => !effectiveSelectedItems.has(getItemId(item)))
|
|
);
|
|
} else {
|
|
fetchData();
|
|
}
|
|
if (!externalSelection) {
|
|
setSelectedItems(new Set());
|
|
}
|
|
} else {
|
|
toast.error(result.error || '삭제에 실패했습니다.');
|
|
}
|
|
} else if (config.actions?.deleteItem) {
|
|
const ids = Array.from(effectiveSelectedItems);
|
|
let successCount = 0;
|
|
for (const id of ids) {
|
|
const result = await config.actions.deleteItem(id);
|
|
if (result.success) successCount++;
|
|
}
|
|
toast.success(`${successCount}건이 삭제되었습니다.`);
|
|
if (config.clientSideFiltering) {
|
|
setRawData((prev) =>
|
|
prev.filter((item) => !effectiveSelectedItems.has(getItemId(item)))
|
|
);
|
|
} else {
|
|
fetchData();
|
|
}
|
|
if (!externalSelection) {
|
|
setSelectedItems(new Set());
|
|
}
|
|
}
|
|
} else if (itemToDelete) {
|
|
if (config.actions?.deleteItem) {
|
|
const id = getItemId(itemToDelete);
|
|
const result = await config.actions.deleteItem(id);
|
|
if (result.success) {
|
|
toast.success('삭제되었습니다.');
|
|
if (config.clientSideFiltering) {
|
|
setRawData((prev) => prev.filter((item) => getItemId(item) !== id));
|
|
} else {
|
|
fetchData();
|
|
}
|
|
} else {
|
|
toast.error(result.error || '삭제에 실패했습니다.');
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[UniversalListPage] Delete error:', error);
|
|
toast.error('삭제 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setDeleteDialogOpen(false);
|
|
setItemToDelete(null);
|
|
}
|
|
}, [config.actions, config.clientSideFiltering, externalSelection, fetchData, getItemId, isBulkDelete, itemToDelete, effectiveSelectedItems]);
|
|
|
|
// ===== 검색 핸들러 =====
|
|
const handleSearchChange = useCallback((value: string) => {
|
|
setSearchValue(value); // UI 즉시 반영
|
|
// 페이지 초기화와 외부 콜백은 debounce 후 실행됨 (아래 useEffect에서 처리)
|
|
}, []);
|
|
|
|
// 외부 콜백에 debounced 값 전달 (자체 loadData 관리하는 컴포넌트용)
|
|
// config.onSearchChange: config 내부에서 설정한 검색 콜백도 호출
|
|
// ⚠️ config.onSearchChange는 deps에서 제외 (config 재생성 → 무한 루프 방지, config.onDataChange 패턴 참고)
|
|
useEffect(() => {
|
|
onSearchChange?.(debouncedSearchValue);
|
|
config.onSearchChange?.(debouncedSearchValue);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [debouncedSearchValue, onSearchChange]);
|
|
|
|
// ===== 필터 핸들러 =====
|
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
|
const newFilters = { ...filters, [key]: value };
|
|
setFilters(newFilters);
|
|
setCurrentPage(1);
|
|
setSelectedItems(new Set());
|
|
// 외부 콜백 호출 (서버 사이드 필터링용)
|
|
onFilterChangeCallback?.(newFilters);
|
|
}, [filters, onFilterChangeCallback]);
|
|
|
|
const handleFilterReset = useCallback(() => {
|
|
setFilters(config.initialFilters || {});
|
|
setCurrentPage(1);
|
|
setSelectedItems(new Set());
|
|
}, [config.initialFilters]);
|
|
|
|
// ===== 엑셀 다운로드 핸들러 =====
|
|
const handleExcelDownload = useCallback(async () => {
|
|
if (!config.excelDownload) return;
|
|
|
|
const { columns, filename = 'export', sheetName = 'Sheet1', fetchAllUrl, fetchAllParams, mapResponse } = config.excelDownload;
|
|
|
|
setIsExcelDownloading(true);
|
|
try {
|
|
let dataToDownload: T[];
|
|
|
|
// 디버깅: 데이터 개수 확인
|
|
|
|
// fetchAllUrl이 있으면 서버에서 전체 데이터 페이지별 순차 조회 (clientSideFiltering 여부 무관)
|
|
if (fetchAllUrl) {
|
|
const PAGE_SIZE = 1000;
|
|
|
|
// 동적 파라미터 구성
|
|
const baseParams = new URLSearchParams();
|
|
if (fetchAllParams) {
|
|
const additionalParams = fetchAllParams({
|
|
activeTab,
|
|
filters,
|
|
searchValue: debouncedSearchValue,
|
|
});
|
|
Object.entries(additionalParams).forEach(([key, value]) => {
|
|
if (value) baseParams.append(key, value);
|
|
});
|
|
}
|
|
|
|
// 1) 첫 페이지 호출로 total 확인
|
|
const firstParams = new URLSearchParams(baseParams);
|
|
firstParams.set('size', String(PAGE_SIZE));
|
|
firstParams.set('per_page', String(PAGE_SIZE));
|
|
firstParams.set('page', '1');
|
|
|
|
const firstResponse = await fetch(`${fetchAllUrl}?${firstParams.toString()}`);
|
|
const firstResult = await firstResponse.json();
|
|
|
|
if (!firstResult.success) {
|
|
throw new Error(firstResult.message || '데이터 조회에 실패했습니다.');
|
|
}
|
|
|
|
const firstPageData = mapResponse
|
|
? mapResponse(firstResult)
|
|
: (firstResult.data?.data ?? firstResult.data ?? []);
|
|
|
|
const total = firstResult.data?.total ?? firstPageData.length;
|
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
|
|
|
|
|
// 2) 나머지 페이지 병렬 호출
|
|
const allData: T[] = [...(firstPageData as T[])];
|
|
|
|
if (totalPages > 1) {
|
|
const remainingPages = Array.from(
|
|
{ length: totalPages - 1 },
|
|
(_, i) => i + 2
|
|
);
|
|
|
|
const pageResults = await Promise.all(
|
|
remainingPages.map(async (page) => {
|
|
const pageParams = new URLSearchParams(baseParams);
|
|
pageParams.set('size', String(PAGE_SIZE));
|
|
pageParams.set('per_page', String(PAGE_SIZE));
|
|
pageParams.set('page', String(page));
|
|
|
|
const res = await fetch(`${fetchAllUrl}?${pageParams.toString()}`);
|
|
const result = await res.json();
|
|
|
|
if (!result.success) {
|
|
console.warn(`[Excel] 페이지 ${page} 조회 실패:`, result.message);
|
|
return [];
|
|
}
|
|
|
|
return mapResponse
|
|
? mapResponse(result)
|
|
: (result.data?.data ?? result.data ?? []);
|
|
})
|
|
);
|
|
|
|
for (const pageData of pageResults) {
|
|
allData.push(...(pageData as T[]));
|
|
}
|
|
}
|
|
|
|
dataToDownload = allData;
|
|
}
|
|
// fetchAllUrl 없으면 현재 로드된 데이터 사용
|
|
else {
|
|
dataToDownload = rawData;
|
|
}
|
|
|
|
if (dataToDownload.length === 0) {
|
|
toast.warning('다운로드할 데이터가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
await downloadExcel({
|
|
data: dataToDownload as Record<string, unknown>[],
|
|
columns: columns as ExcelColumn<Record<string, unknown>>[],
|
|
filename,
|
|
sheetName,
|
|
});
|
|
|
|
toast.success(`${dataToDownload.length}건 다운로드 완료`);
|
|
} catch (error) {
|
|
console.error('[Excel] 다운로드 실패:', error);
|
|
toast.error(error instanceof Error ? error.message : '엑셀 다운로드에 실패했습니다.');
|
|
} finally {
|
|
setIsExcelDownloading(false);
|
|
}
|
|
}, [config.excelDownload, config.clientSideFiltering, filteredData, rawData, activeTab, filters, debouncedSearchValue]);
|
|
|
|
// 선택 항목 엑셀 다운로드
|
|
const handleSelectedExcelDownload = useCallback(async () => {
|
|
if (!config.excelDownload) return;
|
|
|
|
const { columns, filename = 'export', sheetName = 'Sheet1' } = config.excelDownload;
|
|
const selectedIds = Array.from(effectiveSelectedItems);
|
|
|
|
if (selectedIds.length === 0) {
|
|
toast.warning('선택된 항목이 없습니다.');
|
|
return;
|
|
}
|
|
|
|
// 현재 데이터에서 선택된 항목 필터링
|
|
const selectedData = rawData.filter((item) => selectedIds.includes(getItemId(item)));
|
|
|
|
await downloadSelectedExcel({
|
|
data: selectedData as Record<string, unknown>[],
|
|
columns: columns as ExcelColumn<Record<string, unknown>>[],
|
|
selectedIds,
|
|
idField: 'id',
|
|
filename: `${filename}_선택`,
|
|
sheetName,
|
|
});
|
|
|
|
toast.success(`${selectedData.length}건 다운로드 완료`);
|
|
}, [config.excelDownload, effectiveSelectedItems, rawData, getItemId]);
|
|
|
|
// 엑셀 전체 다운로드 버튼 (헤더 영역)
|
|
const renderExcelDownloadButton = useMemo(() => {
|
|
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleExcelDownload}
|
|
disabled={isExcelDownloading}
|
|
className="gap-2"
|
|
>
|
|
{isExcelDownloading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Download className="h-4 w-4" />
|
|
)}
|
|
{isExcelDownloading ? '다운로드 중...' : '엑셀 다운로드'}
|
|
</Button>
|
|
);
|
|
}, [config.excelDownload, canExport, isExcelDownloading, handleExcelDownload]);
|
|
|
|
// 엑셀 선택 다운로드 버튼 (selectionActions 영역 - "전체 N건 / N개 항목 선택됨" 뒤)
|
|
const renderExcelSelectedDownloadButton = useMemo(() => {
|
|
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
|
|
return null;
|
|
}
|
|
const { enableSelectedDownload = true } = config.excelDownload;
|
|
if (!enableSelectedDownload || effectiveSelectedItems.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleSelectedExcelDownload}
|
|
className="gap-2"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
선택 다운로드 ({effectiveSelectedItems.size})
|
|
</Button>
|
|
);
|
|
}, [config.excelDownload, canExport, effectiveSelectedItems.size, handleSelectedExcelDownload]);
|
|
|
|
// ===== 정렬 핸들러 =====
|
|
const handleSort = useCallback((key: string) => {
|
|
let newSortBy: string | undefined;
|
|
let newSortOrder: 'asc' | 'desc' = 'asc';
|
|
|
|
if (sortBy === key) {
|
|
// 같은 컬럼 클릭: asc → desc → 정렬 해제
|
|
if (sortOrder === 'asc') {
|
|
newSortBy = key;
|
|
newSortOrder = 'desc';
|
|
} else {
|
|
newSortBy = undefined;
|
|
newSortOrder = 'asc';
|
|
}
|
|
} else {
|
|
// 다른 컬럼 클릭: 해당 컬럼으로 asc 정렬
|
|
newSortBy = key;
|
|
newSortOrder = 'asc';
|
|
}
|
|
|
|
setSortBy(newSortBy);
|
|
setSortOrder(newSortOrder);
|
|
setCurrentPage(1);
|
|
|
|
// 서버 사이드 정렬: 부모 컴포넌트에 콜백 전달
|
|
if (!config.clientSideFiltering && config.onSortChange) {
|
|
config.onSortChange(newSortBy, newSortOrder);
|
|
}
|
|
}, [sortBy, sortOrder, config.clientSideFiltering, config.onSortChange]);
|
|
|
|
// ===== 탭 핸들러 =====
|
|
const handleTabChange = useCallback((value: string) => {
|
|
setActiveTab(value);
|
|
setCurrentPage(1);
|
|
setSelectedItems(new Set());
|
|
// 외부 콜백 호출 (서버 사이드 필터링용)
|
|
onTabChange?.(value);
|
|
}, [onTabChange]);
|
|
|
|
// ===== 페이지네이션 핸들러 =====
|
|
const handlePageChange = useCallback((page: number) => {
|
|
setCurrentPage(page);
|
|
setSelectedItems(new Set());
|
|
}, []);
|
|
|
|
// ===== 통계 카드 계산 =====
|
|
const computedStats = useMemo(() => {
|
|
if (config.computeStats) {
|
|
return config.computeStats(
|
|
config.clientSideFiltering ? rawData : displayData,
|
|
config.clientSideFiltering ? rawData.length : totalCount
|
|
);
|
|
}
|
|
return config.stats;
|
|
}, [config.computeStats, config.stats, config.clientSideFiltering, rawData, displayData, totalCount]);
|
|
|
|
// ===== 필터 값 변환 =====
|
|
const filterValuesObj: FilterValues = useMemo(() => {
|
|
return filters as FilterValues;
|
|
}, [filters]);
|
|
|
|
// ===== 탭별 컬럼 선택 + sortable 기본값 적용 =====
|
|
const effectiveColumns = useMemo(() => {
|
|
const baseColumns = config.columnsPerTab && activeTab && config.columnsPerTab[activeTab]
|
|
? config.columnsPerTab[activeTab]
|
|
: config.columns;
|
|
|
|
// sortable 기본값 적용:
|
|
// - NON_SORTABLE_KEYS에 해당하는 키: 기본 false
|
|
// - 그 외 모든 데이터 컬럼: 기본 true
|
|
// - 명시적으로 지정된 값이 있으면 그 값 사용
|
|
return baseColumns.map(col => ({
|
|
...col,
|
|
sortable: col.sortable ?? !NON_SORTABLE_KEYS.includes(col.key),
|
|
}));
|
|
}, [config.columns, config.columnsPerTab, activeTab]);
|
|
|
|
// ===== 컬럼 리사이즈 & 가시성 설정 (자동 활성화) =====
|
|
const enableColumnSettings = !config.disableColumnSettings;
|
|
const alwaysVisibleKeys = useMemo(
|
|
() => effectiveColumns.filter(col => NON_SORTABLE_KEYS.includes(col.key)).map(col => col.key),
|
|
[effectiveColumns]
|
|
);
|
|
const {
|
|
visibleColumns: colSettingsVisible,
|
|
allColumnsWithVisibility,
|
|
columnWidths,
|
|
setColumnWidth,
|
|
toggleColumnVisibility,
|
|
resetSettings,
|
|
hasHiddenColumns,
|
|
} = useColumnSettings({
|
|
pageId: config.basePath,
|
|
columns: effectiveColumns,
|
|
alwaysVisibleKeys,
|
|
});
|
|
const hiddenColumnKeys = useMemo(
|
|
() => allColumnsWithVisibility.filter(c => !c.visible).map(c => c.key),
|
|
[allColumnsWithVisibility]
|
|
);
|
|
const templateColumns = enableColumnSettings ? colSettingsVisible : effectiveColumns;
|
|
const templateColumnSettings = useMemo(() => {
|
|
if (!enableColumnSettings) return undefined;
|
|
return {
|
|
columnWidths,
|
|
onColumnResize: setColumnWidth,
|
|
settingsPopover: (
|
|
<ColumnSettingsPopover
|
|
columns={allColumnsWithVisibility}
|
|
onToggle={toggleColumnVisibility}
|
|
onReset={resetSettings}
|
|
hasHiddenColumns={hasHiddenColumns}
|
|
/>
|
|
),
|
|
};
|
|
}, [enableColumnSettings, columnWidths, setColumnWidth, allColumnsWithVisibility, toggleColumnVisibility, resetSettings, hasHiddenColumns]);
|
|
|
|
// ===== ID로 아이템 찾기 헬퍼 =====
|
|
const getItemById = useCallback(
|
|
(id: string): T | undefined => {
|
|
return rawData.find((item) => getItemId(item) === id);
|
|
},
|
|
[rawData, getItemId]
|
|
);
|
|
|
|
// ===== 페이지네이션 config =====
|
|
// 외부 페이지네이션 사용 시 외부 설정 사용
|
|
// 단, 서버 사이드 검색 모드(searchFilter)에서는 필터링된 데이터 기준으로 재계산
|
|
const paginationConfig: PaginationConfig = useMemo(
|
|
() => {
|
|
if (isServerSearchFiltered && externalPagination) {
|
|
return {
|
|
...externalPagination,
|
|
currentPage,
|
|
totalPages: Math.ceil(filteredData.length / itemsPerPage) || 1,
|
|
totalItems: filteredData.length,
|
|
onPageChange: handlePageChange,
|
|
};
|
|
}
|
|
return externalPagination ?? {
|
|
currentPage,
|
|
totalPages,
|
|
totalItems: totalCount,
|
|
itemsPerPage,
|
|
onPageChange: handlePageChange,
|
|
};
|
|
},
|
|
[externalPagination, isServerSearchFiltered, filteredData.length, currentPage, totalPages, totalCount, itemsPerPage, handlePageChange]
|
|
);
|
|
|
|
// ===== 렌더링 함수 래퍼 =====
|
|
const showCheckbox = config.showCheckbox !== false;
|
|
const renderTableRow = useCallback(
|
|
(item: T, index: number, globalIndex: number) => {
|
|
const id = effectiveGetItemId(item);
|
|
const isSelected = effectiveSelectedItems.has(id);
|
|
const row = config.renderTableRow(item, index, globalIndex, {
|
|
isSelected,
|
|
onToggle: () => toggleSelection(id),
|
|
onRowClick: () => handleRowClick(item),
|
|
onEdit: () => handleEdit(item),
|
|
onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined,
|
|
});
|
|
|
|
// 컬럼 설정 활성화 시 숨긴 컬럼의 셀을 React.Children으로 제거
|
|
if (!enableColumnSettings || hiddenColumnKeys.length === 0) return row;
|
|
if (!React.isValidElement(row)) return row;
|
|
|
|
const children = React.Children.toArray((row as React.ReactElement<{ children?: React.ReactNode }>).props.children);
|
|
const offset = showCheckbox ? 1 : 0;
|
|
const filtered = children.filter((_, cellIndex) => {
|
|
if (showCheckbox && cellIndex === 0) return true; // 체크박스 유지
|
|
const col = effectiveColumns[cellIndex - offset];
|
|
return !col || !hiddenColumnKeys.includes(col.key);
|
|
});
|
|
return React.cloneElement(row as React.ReactElement, {}, ...filtered);
|
|
},
|
|
[config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection, enableColumnSettings, hiddenColumnKeys, showCheckbox, effectiveColumns]
|
|
);
|
|
|
|
const renderMobileCard = useCallback(
|
|
(item: T, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
|
return config.renderMobileCard(item, index, globalIndex, {
|
|
isSelected,
|
|
onToggle,
|
|
onRowClick: () => handleRowClick(item),
|
|
onEdit: () => handleEdit(item),
|
|
onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined,
|
|
});
|
|
},
|
|
[config, handleDeleteClick, handleEdit, handleRowClick]
|
|
);
|
|
|
|
// ===== 삭제 확인 메시지 =====
|
|
const deleteConfirmTitle = config.deleteConfirmMessage?.title || '삭제 확인';
|
|
const deleteConfirmDescription =
|
|
config.deleteConfirmMessage?.description ||
|
|
(isBulkDelete
|
|
? `선택한 ${effectiveSelectedItems.size}건을 삭제하시겠습니까?`
|
|
: '이 항목을 삭제하시겠습니까?');
|
|
|
|
return (
|
|
<>
|
|
<IntegratedListTemplateV2
|
|
// 페이지 헤더
|
|
title={config.title}
|
|
description={config.description}
|
|
icon={config.icon}
|
|
headerActions={
|
|
<>
|
|
{/* 엑셀 다운로드 버튼 (config.excelDownload 설정 시 자동 추가) */}
|
|
{renderExcelDownloadButton}
|
|
{/* 커스텀 헤더 액션 */}
|
|
{config.headerActions?.({
|
|
onCreate: handleCreate,
|
|
selectedItems: effectiveSelectedItems,
|
|
onClearSelection: () => setSelectedItems(new Set()),
|
|
onRefresh: fetchData,
|
|
})}
|
|
</>
|
|
}
|
|
// 공통 헤더 옵션 (달력/등록버튼)
|
|
dateRangeSelector={config.dateRangeSelector}
|
|
createButton={permCanCreate ? config.createButton : undefined}
|
|
// 탭 콘텐츠
|
|
tabsContent={config.tabsContent}
|
|
// 통계 카드
|
|
stats={computedStats}
|
|
// 경고 배너
|
|
alertBanner={config.alertBanner}
|
|
// 검색 및 필터
|
|
// hideSearch: true이면서 config에 onSearchChange/searchFilter가 없으면 검색 완전 비활성화
|
|
// hideSearch: true이면서 onSearchChange/searchFilter가 있으면 헤더 검색창만 표시 (Card SearchFilter 숨김)
|
|
searchValue={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : searchValue}
|
|
onSearchChange={(config.hideSearch && !config.onSearchChange && !config.searchFilter) ? undefined : handleSearchChange}
|
|
searchPlaceholder={config.searchPlaceholder}
|
|
extraFilters={config.extraFilters}
|
|
hideSearch={config.hideSearch}
|
|
// 탭 (빈 배열일 때는 undefined로 전달해서 IntegratedListTemplateV2의 기본 탭 사용)
|
|
tabs={computedTabs.length > 0 ? computedTabs : undefined}
|
|
activeTab={activeTab}
|
|
onTabChange={handleTabChange}
|
|
tabsPosition={config.tabsPosition}
|
|
// 필터 시스템
|
|
filterConfig={config.filterConfig}
|
|
filterValues={filterValuesObj}
|
|
onFilterChange={handleFilterChange}
|
|
onFilterReset={handleFilterReset}
|
|
filterTitle={config.filterTitle}
|
|
// 테이블 앞 콘텐츠 (함수일 경우 params 전달)
|
|
beforeTableContent={
|
|
typeof config.beforeTableContent === 'function'
|
|
? config.beforeTableContent({
|
|
selectedItems: effectiveSelectedItems,
|
|
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
|
|
})
|
|
: config.beforeTableContent
|
|
}
|
|
// 테이블 헤더 액션 (함수일 경우 params 전달)
|
|
tableHeaderActions={
|
|
typeof config.tableHeaderActions === 'function'
|
|
? config.tableHeaderActions({
|
|
totalCount: isServerSearchFiltered ? filteredData.length : (externalPagination?.totalItems ?? totalCount),
|
|
selectedItems: effectiveSelectedItems,
|
|
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
|
|
})
|
|
: config.tableHeaderActions
|
|
}
|
|
// 테이블 컬럼 (가시성 필터링 적용)
|
|
tableColumns={templateColumns}
|
|
// 컬럼 리사이즈 & 가시성 설정
|
|
columnSettings={templateColumnSettings}
|
|
// 정렬 설정 (모든 페이지에서 활성화)
|
|
sortBy={sortBy}
|
|
sortOrder={sortOrder}
|
|
onSort={handleSort}
|
|
// 커스텀 테이블 헤더 (동적 컬럼용)
|
|
renderCustomTableHeader={
|
|
config.renderCustomTableHeader
|
|
? () =>
|
|
config.renderCustomTableHeader!({
|
|
displayData,
|
|
selectedItems: effectiveSelectedItems,
|
|
onToggleSelectAll: toggleSelectAll,
|
|
})
|
|
: undefined
|
|
}
|
|
// 테이블 푸터
|
|
tableFooter={config.tableFooter}
|
|
// 테이블 뒤 컨텐츠 (캘린더 등)
|
|
afterTableContent={
|
|
typeof config.afterTableContent === 'function'
|
|
? config.afterTableContent({ data: displayData, selectedItems: effectiveSelectedItems })
|
|
: config.afterTableContent
|
|
}
|
|
// 데이터
|
|
data={displayData}
|
|
totalCount={totalCount}
|
|
allData={config.clientSideFiltering ? filteredData : undefined}
|
|
// 모바일 인피니티 스크롤 로딩 상태
|
|
isMobileLoading={isMobileLoading}
|
|
// 체크박스 선택
|
|
selectedItems={effectiveSelectedItems}
|
|
onToggleSelection={toggleSelection}
|
|
onToggleSelectAll={toggleSelectAll}
|
|
getItemId={effectiveGetItemId}
|
|
onBulkDelete={config.actions?.deleteItem ? handleBulkDeleteClick : undefined}
|
|
selectionActions={
|
|
<>
|
|
{renderExcelSelectedDownloadButton}
|
|
{config.selectionActions?.({
|
|
selectedItems: effectiveSelectedItems,
|
|
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
|
|
onRefresh: fetchData,
|
|
})}
|
|
</>
|
|
}
|
|
// 표시 옵션
|
|
showCheckbox={config.showCheckbox}
|
|
showRowNumber={config.showRowNumber}
|
|
// 렌더링 함수
|
|
renderTableRow={renderTableRow}
|
|
renderMobileCard={renderMobileCard}
|
|
// 페이지네이션
|
|
pagination={paginationConfig}
|
|
// 로딩 상태 (외부 로딩 상태 우선 사용)
|
|
isLoading={externalIsLoading ?? isLoading}
|
|
/>
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<DeleteConfirmDialog
|
|
open={deleteDialogOpen}
|
|
onOpenChange={setDeleteDialogOpen}
|
|
onConfirm={handleDeleteConfirm}
|
|
title={deleteConfirmTitle}
|
|
description={deleteConfirmDescription}
|
|
/>
|
|
|
|
{/* 상세 모달 (detailMode === 'modal'일 때) */}
|
|
{config.detailMode === 'modal' && config.DetailModalComponent && (
|
|
<config.DetailModalComponent
|
|
isOpen={isModalOpen}
|
|
onClose={() => {
|
|
setIsModalOpen(false);
|
|
setSelectedItem(null);
|
|
}}
|
|
item={selectedItem}
|
|
onRefresh={fetchData}
|
|
/>
|
|
)}
|
|
|
|
{/* 커스텀 다이얼로그 슬롯 */}
|
|
{config.renderDialogs?.({
|
|
data: displayData,
|
|
selectedItems: effectiveSelectedItems,
|
|
activeTab,
|
|
onRefresh: fetchData,
|
|
getItemById,
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 타입 re-export
|
|
export * from './types'; |