- 등록(?mode=new), 상세(?mode=view), 수정(?mode=edit) URL 패턴 일괄 적용
- 중복 패턴 제거: /edit?mode=edit → ?mode=edit (16개 파일)
- 제목 일관성: {기능} 등록/상세/수정 패턴 적용
- 검수 체크리스트 문서 추가 (79개 페이지)
- UniversalListPage, IntegratedDetailTemplate 공통 컴포넌트 개선
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
638 lines
22 KiB
TypeScript
638 lines
22 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* UniversalListPage - 통합 리스트 페이지 컴포넌트
|
|
*
|
|
* 59개 리스트 페이지를 하나의 config 기반 컴포넌트로 통합
|
|
* 기존 기능 100% 유지, 테이블 영역만 공통화
|
|
*
|
|
* 지원 모드:
|
|
* - 서버 사이드 필터링/페이지네이션 (기본)
|
|
* - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true)
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useRouter, useParams } from 'next/navigation';
|
|
import { toast } from 'sonner';
|
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import {
|
|
IntegratedListTemplateV2,
|
|
type PaginationConfig,
|
|
} from '@/components/templates/IntegratedListTemplateV2';
|
|
import type {
|
|
UniversalListPageProps,
|
|
TabOption,
|
|
FilterValues,
|
|
} 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 [rawData, setRawData] = useState<T[]>(initialData || []);
|
|
// UI 상태
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(!initialData);
|
|
const [searchValue, setSearchValue] = useState('');
|
|
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 || []);
|
|
|
|
// 모달 상태 (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 itemsPerPage = config.itemsPerPage || 20;
|
|
|
|
// 모바일 인피니티 스크롤 로딩 상태 (서버 사이드 페이지네이션 시)
|
|
const [isMobileLoading, setIsMobileLoading] = useState(false);
|
|
|
|
// ===== ID 추출 헬퍼 =====
|
|
const getItemId = useCallback(
|
|
(item: T): string => {
|
|
if (typeof config.idField === 'function') {
|
|
return config.idField(item);
|
|
}
|
|
return String(item[config.idField]);
|
|
},
|
|
[config.idField]
|
|
);
|
|
|
|
// ===== 클라이언트 사이드 필터링 =====
|
|
const filteredData = useMemo(() => {
|
|
if (!config.clientSideFiltering) {
|
|
return rawData;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
// 검색 필터
|
|
if (searchValue && config.searchFilter) {
|
|
filtered = filtered.filter((item) =>
|
|
config.searchFilter!(item, searchValue)
|
|
);
|
|
}
|
|
|
|
// 커스텀 정렬 함수
|
|
if (config.customSortFn) {
|
|
filtered = config.customSortFn(filtered, filters);
|
|
}
|
|
|
|
return filtered;
|
|
}, [rawData, activeTab, searchValue, filters, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn]);
|
|
|
|
// 클라이언트 사이드 페이지네이션
|
|
const paginatedData = useMemo(() => {
|
|
if (!config.clientSideFiltering) {
|
|
return rawData;
|
|
}
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
return filteredData.slice(startIndex, startIndex + itemsPerPage);
|
|
}, [config.clientSideFiltering, filteredData, currentPage, itemsPerPage, rawData]);
|
|
|
|
// 총 개수 및 페이지 수
|
|
const totalCount = config.clientSideFiltering ? filteredData.length : rawData.length;
|
|
const totalPages = Math.ceil(totalCount / itemsPerPage);
|
|
|
|
// 표시할 데이터
|
|
const displayData = config.clientSideFiltering ? paginatedData : rawData;
|
|
|
|
// ===== 탭 카운트 계산 (클라이언트 사이드) =====
|
|
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 {
|
|
setIsLoading(true);
|
|
}
|
|
|
|
try {
|
|
const result = await config.actions.getList(
|
|
config.clientSideFiltering
|
|
? { pageSize: 9999 } // 클라이언트 사이드: 전체 데이터 로드
|
|
: {
|
|
page: currentPage,
|
|
pageSize: itemsPerPage,
|
|
search: searchValue,
|
|
filters,
|
|
tab: activeTab,
|
|
}
|
|
);
|
|
|
|
if (result.success && result.data) {
|
|
setRawData(result.data);
|
|
} else {
|
|
toast.error(result.error || '데이터를 불러오는데 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('[UniversalListPage] Fetch error:', error);
|
|
toast.error('데이터를 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsMobileLoading(false);
|
|
}
|
|
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]);
|
|
|
|
// 초기 로딩 (initialData가 없거나 빈 배열인 경우)
|
|
useEffect(() => {
|
|
if (!initialData || initialData.length === 0) {
|
|
fetchData();
|
|
}
|
|
}, []);
|
|
|
|
// initialData prop 변경 감지 (부모 컴포넌트에서 데이터 로드 후 전달하는 경우)
|
|
useEffect(() => {
|
|
if (initialData && initialData.length > 0) {
|
|
setRawData(initialData);
|
|
}
|
|
}, [initialData]);
|
|
|
|
// config.tabs 변경 감지 (동적 탭 카운트 업데이트용)
|
|
useEffect(() => {
|
|
if (config.tabs) {
|
|
setTabs(config.tabs);
|
|
}
|
|
}, [config.tabs]);
|
|
|
|
// 데이터 변경 콜백 (동적 컬럼 계산 등에 사용)
|
|
// ⚠️ config.onDataChange를 deps에서 제외: 콜백 참조 변경으로 인한 무한 루프 방지
|
|
useEffect(() => {
|
|
config.onDataChange?.(rawData);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [rawData]);
|
|
|
|
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
|
|
// 이전 페이지를 추적하여 모바일 인피니티 스크롤 감지
|
|
const [prevPage, setPrevPage] = useState(1);
|
|
useEffect(() => {
|
|
if (!config.clientSideFiltering && !isLoading && !isMobileLoading) {
|
|
// 페이지가 증가하는 경우 = 모바일 인피니티 스크롤
|
|
const isMobileAppend = currentPage > prevPage && currentPage > 1;
|
|
fetchData(isMobileAppend);
|
|
setPrevPage(currentPage);
|
|
}
|
|
}, [currentPage, searchValue, filters, activeTab]);
|
|
|
|
// 동적 탭 로딩
|
|
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);
|
|
setCurrentPage(1);
|
|
setSelectedItems(new Set());
|
|
// 외부 콜백 호출 (서버 사이드 검색용)
|
|
onSearchChange?.(value);
|
|
}, [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 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]);
|
|
|
|
// ===== 탭별 컬럼 선택 =====
|
|
const effectiveColumns = useMemo(() => {
|
|
if (config.columnsPerTab && activeTab && config.columnsPerTab[activeTab]) {
|
|
return config.columnsPerTab[activeTab];
|
|
}
|
|
return config.columns;
|
|
}, [config.columns, config.columnsPerTab, activeTab]);
|
|
|
|
// ===== ID로 아이템 찾기 헬퍼 =====
|
|
const getItemById = useCallback(
|
|
(id: string): T | undefined => {
|
|
return rawData.find((item) => getItemId(item) === id);
|
|
},
|
|
[rawData, getItemId]
|
|
);
|
|
|
|
// ===== 페이지네이션 config =====
|
|
// 외부 페이지네이션 사용 시 외부 설정 사용
|
|
const paginationConfig: PaginationConfig = useMemo(
|
|
() => externalPagination ?? {
|
|
currentPage,
|
|
totalPages,
|
|
totalItems: totalCount,
|
|
itemsPerPage,
|
|
onPageChange: handlePageChange,
|
|
},
|
|
[externalPagination, currentPage, totalPages, totalCount, itemsPerPage, handlePageChange]
|
|
);
|
|
|
|
// ===== 렌더링 함수 래퍼 =====
|
|
const renderTableRow = useCallback(
|
|
(item: T, index: number, globalIndex: number) => {
|
|
const id = effectiveGetItemId(item);
|
|
const isSelected = effectiveSelectedItems.has(id);
|
|
return config.renderTableRow(item, index, globalIndex, {
|
|
isSelected,
|
|
onToggle: () => toggleSelection(id),
|
|
onRowClick: () => handleRowClick(item),
|
|
onEdit: () => handleEdit(item),
|
|
onDelete: () => handleDeleteClick(item),
|
|
});
|
|
},
|
|
[config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection]
|
|
);
|
|
|
|
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: () => handleDeleteClick(item),
|
|
});
|
|
},
|
|
[config, handleDeleteClick, handleEdit, handleRowClick]
|
|
);
|
|
|
|
// ===== 삭제 확인 메시지 =====
|
|
const deleteConfirmTitle = config.deleteConfirmMessage?.title || '삭제 확인';
|
|
const deleteConfirmDescription =
|
|
config.deleteConfirmMessage?.description ||
|
|
(isBulkDelete
|
|
? `선택한 ${effectiveSelectedItems.size}건을 삭제하시겠습니까?`
|
|
: '이 항목을 삭제하시겠습니까?');
|
|
|
|
return (
|
|
<>
|
|
<IntegratedListTemplateV2<T>
|
|
// 페이지 헤더
|
|
title={config.title}
|
|
description={config.description}
|
|
icon={config.icon}
|
|
headerActions={config.headerActions?.({
|
|
onCreate: handleCreate,
|
|
selectedItems: effectiveSelectedItems,
|
|
onClearSelection: () => setSelectedItems(new Set()),
|
|
onRefresh: fetchData,
|
|
})}
|
|
// 공통 헤더 옵션 (달력/등록버튼)
|
|
dateRangeSelector={config.dateRangeSelector}
|
|
createButton={config.createButton}
|
|
// 탭 콘텐츠
|
|
tabsContent={config.tabsContent}
|
|
// 통계 카드
|
|
stats={computedStats}
|
|
// 경고 배너
|
|
alertBanner={config.alertBanner}
|
|
// 검색 및 필터
|
|
searchValue={searchValue}
|
|
onSearchChange={handleSearchChange}
|
|
searchPlaceholder={config.searchPlaceholder}
|
|
extraFilters={config.extraFilters}
|
|
hideSearch={config.hideSearch}
|
|
// 탭 (빈 배열일 때는 undefined로 전달해서 IntegratedListTemplateV2의 기본 탭 사용)
|
|
tabs={computedTabs.length > 0 ? computedTabs : undefined}
|
|
activeTab={activeTab}
|
|
onTabChange={handleTabChange}
|
|
// 필터 시스템
|
|
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: externalPagination?.totalItems ?? totalCount,
|
|
selectedItems: effectiveSelectedItems,
|
|
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
|
|
})
|
|
: config.tableHeaderActions
|
|
}
|
|
// 테이블 컬럼 (탭별 다른 컬럼 지원)
|
|
tableColumns={effectiveColumns}
|
|
// 커스텀 테이블 헤더 (동적 컬럼용)
|
|
renderCustomTableHeader={
|
|
config.renderCustomTableHeader
|
|
? () =>
|
|
config.renderCustomTableHeader!({
|
|
displayData,
|
|
selectedItems: effectiveSelectedItems,
|
|
onToggleSelectAll: toggleSelectAll,
|
|
})
|
|
: undefined
|
|
}
|
|
// 테이블 푸터
|
|
tableFooter={config.tableFooter}
|
|
// 데이터
|
|
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}
|
|
// 표시 옵션
|
|
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'; |