'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({ config, initialData, initialTotalCount, externalPagination, externalSelection, onTabChange, onSearchChange, onFilterChange: onFilterChangeCallback, externalIsLoading, }: UniversalListPageProps) { const router = useRouter(); const params = useParams(); const locale = (params.locale as string) || 'ko'; // ===== 상태 관리 ===== // 원본 데이터 (클라이언트 사이드 필터링용) const [rawData, setRawData] = useState(initialData || []); // UI 상태 const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(!initialData); const [searchValue, setSearchValue] = useState(''); const [selectedItems, setSelectedItems] = useState>(new Set()); // 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함 const [activeTab, setActiveTab] = useState( config.defaultTab || config.tabs?.[0]?.value || 'default' ); const [filters, setFilters] = useState>( config.initialFilters || {} ); const [tabs, setTabs] = useState(config.tabs || []); // 모달 상태 (detailMode === 'modal'일 때 사용) const [isModalOpen, setIsModalOpen] = useState(false); const [selectedItem, setSelectedItem] = useState(null); // 삭제 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [itemToDelete, setItemToDelete] = useState(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 ( <> // 페이지 헤더 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} /> {/* 삭제 확인 다이얼로그 */} {/* 상세 모달 (detailMode === 'modal'일 때) */} {config.detailMode === 'modal' && config.DetailModalComponent && ( { setIsModalOpen(false); setSelectedItem(null); }} item={selectedItem} onRefresh={fetchData} /> )} {/* 커스텀 다이얼로그 슬롯 */} {config.renderDialogs?.({ data: displayData, selectedItems: effectiveSelectedItems, activeTab, onRefresh: fetchData, getItemById, })} ); } // 타입 re-export export * from './types';