feat(WEB): UniversalListPage 전체 마이그레이션 및 코드 정리

- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선
- 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션
- 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등)
- 미들웨어 토큰 갱신 로직 개선
- AuthenticatedLayout 구조 개선
- claudedocs 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-16 15:19:09 +09:00
parent 8639eee5df
commit ad493bcea6
90 changed files with 19864 additions and 20305 deletions

View File

@@ -1,7 +1,8 @@
"use client";
import { ReactNode, Fragment, useState, RefObject } from "react";
import { LucideIcon, Trash2 } from "lucide-react";
import { LucideIcon, Trash2, Plus } from "lucide-react";
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@@ -100,6 +101,34 @@ export interface IntegratedListTemplateV2Props<T = any> {
icon?: LucideIcon;
headerActions?: ReactNode;
// ===== 공통 헤더 옵션 (달력/등록버튼) =====
/**
* 날짜 범위 선택기 (왼쪽 배치)
* - enabled: 달력 표시 여부
* - showPresets: 프리셋 버튼 (당해년도, 전전월, 전월, 당월, 어제, 오늘)
* - startDate/endDate: 외부 상태 연동
* - onChange: 날짜 변경 콜백
*/
dateRangeSelector?: {
enabled: boolean;
showPresets?: boolean;
startDate?: string;
endDate?: string;
onStartDateChange?: (date: string) => void;
onEndDateChange?: (date: string) => void;
};
/**
* 등록 버튼 (오른쪽 끝 배치)
* - label: 버튼 텍스트 (예: '등록', '공정 등록')
* - onClick: 클릭 핸들러
* - icon: 아이콘 (기본: Plus)
*/
createButton?: {
label: string;
onClick: () => void;
icon?: LucideIcon;
};
// 탭 콘텐츠 (헤더 액션 아래, 검색 위에 표시되는 커스텀 탭)
tabsContent?: ReactNode;
@@ -191,6 +220,8 @@ export function IntegratedListTemplateV2<T = any>({
description,
icon,
headerActions,
dateRangeSelector,
createButton,
tabsContent,
stats,
alertBanner,
@@ -333,9 +364,32 @@ export function IntegratedListTemplateV2<T = any>({
/>
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
{headerActions && (
{/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
{(dateRangeSelector?.enabled || createButton || headerActions) && (
<div className="flex items-center gap-2 flex-wrap w-full">
{/* 날짜 범위 선택기 (왼쪽) */}
{dateRangeSelector?.enabled && (
<DateRangeSelector
startDate={dateRangeSelector.startDate || ''}
endDate={dateRangeSelector.endDate || ''}
onStartDateChange={dateRangeSelector.onStartDateChange}
onEndDateChange={dateRangeSelector.onEndDateChange}
hidePresets={dateRangeSelector.showPresets === false}
/>
)}
{/* 레거시 헤더 액션 (기존 호환성 유지) */}
{headerActions}
{/* 등록 버튼 (오른쪽 끝) */}
{createButton && (
<Button className="ml-auto" onClick={createButton.onClick}>
{createButton.icon ? (
<createButton.icon className="h-4 w-4 mr-2" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{createButton.label}
</Button>
)}
</div>
)}
@@ -412,10 +466,10 @@ export function IntegratedListTemplateV2<T = any>({
{selectedItems.size}
</span>
)}
{/* 테이블 헤더 액션 (총 N건 등) - 필터 앞에 배치 */}
{tableHeaderActions}
{/* filterConfig 기반 자동 필터 (PC) */}
{renderAutoFilters()}
{/* 테이블 헤더 액션 (필터/정렬 셀렉트박스 등) - 기존 방식 */}
{tableHeaderActions}
{selectedItems.size >= 1 && onBulkDelete && (
<Button
variant="outline"
@@ -431,6 +485,24 @@ export function IntegratedListTemplateV2<T = any>({
</div>
</div>
{/* 모바일/태블릿 (~1279px) - TabChip 탭 */}
{tabs && tabs.length > 0 && (
<div className="xl:hidden mb-4 overflow-x-auto">
<div className="flex gap-2 min-w-max">
{tabs.map((tab) => (
<TabChip
key={tab.value}
label={tab.label}
count={tab.count}
active={activeTab === tab.value}
onClick={() => onTabChange?.(tab.value)}
color={tab.color as any}
/>
))}
</div>
</div>
)}
{/* 탭 컨텐츠 */}
{(tabs || [{ value: 'default', label: '', count: 0 }]).map((tab) => (
<TabsContent key={tab.value} value={tab.value} className="mt-0">
@@ -568,11 +640,11 @@ export function IntegratedListTemplateV2<T = any>({
</Card>
{/* 페이지네이션 - 데스크톱에서만 표시 */}
{pagination.totalPages > 1 && (
<div className="hidden xl:flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{pagination.totalItems} {startIndex + 1}-{Math.min(startIndex + pagination.itemsPerPage, pagination.totalItems)}
</div>
<div className="hidden xl:flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{pagination.totalItems} {pagination.totalItems > 0 ? startIndex + 1 : 0}-{Math.min(startIndex + pagination.itemsPerPage, pagination.totalItems)}
</div>
{pagination.totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
@@ -619,8 +691,8 @@ export function IntegratedListTemplateV2<T = any>({
</Button>
</div>
</div>
)}
)}
</div>
{/* 일괄 삭제 확인 다이얼로그 - 단일 삭제와 동일한 디자인 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>

View File

@@ -38,6 +38,11 @@ export function UniversalListPage<T>({
config,
initialData,
initialTotalCount,
externalPagination,
externalSelection,
onTabChange,
onSearchChange,
onFilterChange: onFilterChangeCallback,
}: UniversalListPageProps<T>) {
const router = useRouter();
const params = useParams();
@@ -176,13 +181,32 @@ export function UniversalListPage<T>({
}
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]);
// 초기 로딩
// 초기 로딩 (initialData가 없거나 빈 배열인 경우)
useEffect(() => {
if (!initialData) {
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]);
// 데이터 변경 콜백 (동적 컬럼 계산 등에 사용)
useEffect(() => {
config.onDataChange?.(rawData);
}, [rawData, config.onDataChange]);
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
useEffect(() => {
if (!config.clientSideFiltering && !isLoading) {
@@ -203,30 +227,42 @@ export function UniversalListPage<T>({
}, [config.fetchTabs]);
// ===== 선택 핸들러 =====
// 외부 선택 상태 사용 시 외부 핸들러 사용
const effectiveSelectedItems = externalSelection?.selectedItems ?? selectedItems;
const effectiveGetItemId = externalSelection?.getItemId ?? getItemId;
const toggleSelection = useCallback(
(id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
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(() => {
const currentData = displayData;
if (selectedItems.size === currentData.length && currentData.length > 0) {
setSelectedItems(new Set());
if (externalSelection) {
externalSelection.onToggleSelectAll();
} else {
const allIds = new Set(currentData.map((item) => getItemId(item)));
setSelectedItems(allIds);
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);
}
}
}, [displayData, selectedItems.size, getItemId]);
}, [externalSelection, displayData, selectedItems.size, getItemId]);
// ===== 행 클릭 핸들러 =====
const handleRowClick = useCallback(
@@ -264,35 +300,37 @@ export function UniversalListPage<T>({
}, []);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
if (effectiveSelectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setIsBulkDelete(true);
setDeleteDialogOpen(true);
}, [selectedItems.size]);
}, [effectiveSelectedItems.size]);
const handleDeleteConfirm = useCallback(async () => {
try {
if (isBulkDelete) {
if (config.actions.deleteBulk) {
const result = await config.actions.deleteBulk(Array.from(selectedItems));
const result = await config.actions.deleteBulk(Array.from(effectiveSelectedItems));
if (result.success) {
toast.success(`${selectedItems.size}건이 삭제되었습니다.`);
toast.success(`${effectiveSelectedItems.size}건이 삭제되었습니다.`);
// 클라이언트 사이드: 로컬 데이터에서 제거
if (config.clientSideFiltering) {
setRawData((prev) =>
prev.filter((item) => !selectedItems.has(getItemId(item)))
prev.filter((item) => !effectiveSelectedItems.has(getItemId(item)))
);
} else {
fetchData();
}
setSelectedItems(new Set());
if (!externalSelection) {
setSelectedItems(new Set());
}
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} else if (config.actions.deleteItem) {
const ids = Array.from(selectedItems);
} 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);
@@ -301,15 +339,17 @@ export function UniversalListPage<T>({
toast.success(`${successCount}건이 삭제되었습니다.`);
if (config.clientSideFiltering) {
setRawData((prev) =>
prev.filter((item) => !selectedItems.has(getItemId(item)))
prev.filter((item) => !effectiveSelectedItems.has(getItemId(item)))
);
} else {
fetchData();
}
setSelectedItems(new Set());
if (!externalSelection) {
setSelectedItems(new Set());
}
}
} else if (itemToDelete) {
if (config.actions.deleteItem) {
if (config.actions?.deleteItem) {
const id = getItemId(itemToDelete);
const result = await config.actions.deleteItem(id);
if (result.success) {
@@ -331,21 +371,26 @@ export function UniversalListPage<T>({
setDeleteDialogOpen(false);
setItemToDelete(null);
}
}, [config.actions, config.clientSideFiltering, fetchData, getItemId, isBulkDelete, itemToDelete, selectedItems]);
}, [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[]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
setCurrentPage(1);
setSelectedItems(new Set());
}, []);
// 외부 콜백 호출 (서버 사이드 필터링용)
onFilterChangeCallback?.(newFilters);
}, [filters, onFilterChangeCallback]);
const handleFilterReset = useCallback(() => {
setFilters(config.initialFilters || {});
@@ -358,7 +403,9 @@ export function UniversalListPage<T>({
setActiveTab(value);
setCurrentPage(1);
setSelectedItems(new Set());
}, []);
// 외부 콜백 호출 (서버 사이드 필터링용)
onTabChange?.(value);
}, [onTabChange]);
// ===== 페이지네이션 핸들러 =====
const handlePageChange = useCallback((page: number) => {
@@ -382,23 +429,40 @@ export function UniversalListPage<T>({
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,
}),
[currentPage, totalPages, totalCount, itemsPerPage, handlePageChange]
},
[externalPagination, currentPage, totalPages, totalCount, itemsPerPage, handlePageChange]
);
// ===== 렌더링 함수 래퍼 =====
const renderTableRow = useCallback(
(item: T, index: number, globalIndex: number) => {
const id = getItemId(item);
const isSelected = selectedItems.has(id);
const id = effectiveGetItemId(item);
const isSelected = effectiveSelectedItems.has(id);
return config.renderTableRow(item, index, globalIndex, {
isSelected,
onToggle: () => toggleSelection(id),
@@ -407,7 +471,7 @@ export function UniversalListPage<T>({
onDelete: () => handleDeleteClick(item),
});
},
[config, getItemId, handleDeleteClick, handleEdit, handleRowClick, selectedItems, toggleSelection]
[config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection]
);
const renderMobileCard = useCallback(
@@ -428,7 +492,7 @@ export function UniversalListPage<T>({
const deleteConfirmDescription =
config.deleteConfirmMessage?.description ||
(isBulkDelete
? `선택한 ${selectedItems.size}건을 삭제하시겠습니까?`
? `선택한 ${effectiveSelectedItems.size}건을 삭제하시겠습니까?`
: '이 항목을 삭제하시겠습니까?');
return (
@@ -438,7 +502,15 @@ export function UniversalListPage<T>({
title={config.title}
description={config.description}
icon={config.icon}
headerActions={config.headerActions?.({ onCreate: handleCreate })}
headerActions={config.headerActions?.({
onCreate: handleCreate,
selectedItems: effectiveSelectedItems,
onClearSelection: () => setSelectedItems(new Set()),
onRefresh: fetchData,
})}
// 공통 헤더 옵션 (달력/등록버튼)
dateRangeSelector={config.dateRangeSelector}
createButton={config.createButton}
// 탭 콘텐츠
tabsContent={config.tabsContent}
// 통계 카드
@@ -461,12 +533,38 @@ export function UniversalListPage<T>({
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle={config.filterTitle}
// 테이블 앞 콘텐츠
beforeTableContent={config.beforeTableContent}
// 테이블 헤더 액션
tableHeaderActions={config.tableHeaderActions}
// 테이블 컬럼
tableColumns={config.columns}
// 테이블 앞 콘텐츠 (함수일 경우 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}
// 데이터
@@ -474,11 +572,11 @@ export function UniversalListPage<T>({
totalCount={totalCount}
allData={config.clientSideFiltering ? filteredData : undefined}
// 체크박스 선택
selectedItems={selectedItems}
selectedItems={effectiveSelectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={getItemId}
onBulkDelete={config.actions.deleteItem ? handleBulkDeleteClick : undefined}
getItemId={effectiveGetItemId}
onBulkDelete={config.actions?.deleteItem ? handleBulkDeleteClick : undefined}
// 표시 옵션
showCheckbox={config.showCheckbox}
showRowNumber={config.showRowNumber}
@@ -517,6 +615,15 @@ export function UniversalListPage<T>({
onRefresh={fetchData}
/>
)}
{/* 커스텀 다이얼로그 슬롯 */}
{config.renderDialogs?.({
data: displayData,
selectedItems: effectiveSelectedItems,
activeTab,
onRefresh: fetchData,
getItemById,
})}
</>
);
}

View File

@@ -128,6 +128,12 @@ export interface UniversalListConfig<T> {
// ===== 테이블 컬럼 =====
columns: TableColumn[];
/**
* 탭별 다른 컬럼 구조 (columnsPerTab 설정 시 columns 대신 사용)
* - key: 탭 value
* - value: 해당 탭에서 사용할 컬럼 배열
*/
columnsPerTab?: Record<string, TableColumn[]>;
// ===== 필터 설정 =====
/** 필터 필드 설정 */
@@ -179,11 +185,66 @@ export interface UniversalListConfig<T> {
}>;
// ===== 커스텀 액션 =====
/** 헤더 액션 (등록 버튼 등) */
headerActions?: (params: { onCreate?: () => void }) => ReactNode;
/**
* 헤더 액션 (선택 기반 동적 버튼 등)
* - onCreate: 등록 버튼 클릭 핸들러
* - selectedItems: 현재 선택된 아이템 ID Set
* - onClearSelection: 선택 해제 함수
* - onRefresh: 데이터 새로고침 함수
*/
headerActions?: (params: {
onCreate?: () => void;
selectedItems: Set<string>;
onClearSelection: () => void;
onRefresh: () => void;
}) => ReactNode;
/** 커스텀 액션 버튼 (상신, 승인 등) */
customActions?: CustomAction<T>[];
// ===== 공통 헤더 옵션 (달력/등록버튼) =====
/**
* 날짜 범위 선택기 (왼쪽 배치)
* - enabled: 달력 표시 여부
* - showPresets: 프리셋 버튼 (당해년도, 전전월, 전월, 당월, 어제, 오늘)
* - startDate/endDate: 외부 상태 연동
* - onChange: 날짜 변경 콜백
*/
dateRangeSelector?: {
enabled: boolean;
showPresets?: boolean;
startDate?: string;
endDate?: string;
onStartDateChange?: (date: string) => void;
onEndDateChange?: (date: string) => void;
};
/**
* 등록 버튼 (오른쪽 끝 배치)
* - label: 버튼 텍스트 (예: '등록', '공정 등록')
* - onClick: 클릭 핸들러
* - icon: 아이콘 (기본: Plus)
*/
createButton?: {
label: string;
onClick: () => void;
icon?: LucideIcon;
};
// ===== 커스텀 테이블 헤더 =====
/**
* 동적 컬럼을 위한 커스텀 테이블 헤더 렌더링 (columns 대신 사용)
* - displayData: 현재 페이지에 표시되는 데이터
* - selectedItems: 선택된 아이템 ID Set
* - onToggleSelectAll: 전체 선택/해제 핸들러
*/
renderCustomTableHeader?: (params: {
displayData: T[];
selectedItems: Set<string>;
onToggleSelectAll: () => void;
}) => ReactNode;
/** 데이터 변경 콜백 (동적 컬럼 계산 등에 사용) */
onDataChange?: (data: T[]) => void;
// ===== 추가 옵션 =====
/** 검색 플레이스홀더 */
searchPlaceholder?: string;
@@ -217,12 +278,25 @@ export interface UniversalListConfig<T> {
customSortFn?: (items: T[], filterValues: Record<string, string | string[]>) => T[];
// ===== 테이블 헤더 액션 =====
/** 테이블 헤더 우측 추가 액션 (총건, 필터 해제 버튼 등) */
tableHeaderActions?: ReactNode;
/** 테이블 헤더 우측 추가 액션 (총건, 필터 해제 버튼, 일괄 액션 등)
* - ReactNode: 정적 콘텐츠
* - 함수: 동적으로 params를 받아서 렌더링
* - totalCount: 총 데이터 수
* - selectedItems: 선택된 아이템 ID Set
* - onClearSelection: 선택 해제 함수
*/
tableHeaderActions?: ReactNode | ((params: {
totalCount: number;
selectedItems: Set<string>;
onClearSelection: () => void;
}) => ReactNode);
// ===== 추가 슬롯 =====
/** 테이블 앞 커스텀 콘텐츠 */
beforeTableContent?: ReactNode;
/** 테이블 앞 커스텀 콘텐츠 (ReactNode 또는 selectedItems를 받는 함수) */
beforeTableContent?: ReactNode | ((props: {
selectedItems: Set<string>;
onClearSelection: () => void;
}) => ReactNode);
/** 테이블 하단 푸터 */
tableFooter?: ReactNode;
/** 경고 배너 */
@@ -231,6 +305,39 @@ export interface UniversalListConfig<T> {
tabsContent?: ReactNode;
/** 추가 필터 (Select, DatePicker 등) */
extraFilters?: ReactNode;
// ===== 커스텀 다이얼로그 슬롯 =====
/**
* 커스텀 다이얼로그 렌더링 (DocumentDetailModal, SalaryDetailDialog 등)
* - data: 현재 표시 중인 데이터 배열
* - selectedItems: 선택된 아이템 ID Set
* - activeTab: 현재 활성 탭
* - onRefresh: 데이터 새로고침 함수
*/
renderDialogs?: (params: {
data: T[];
selectedItems: Set<string>;
activeTab: string;
onRefresh: () => void;
getItemById: (id: string) => T | undefined;
}) => ReactNode;
}
// ===== 외부 페이지네이션 설정 =====
export interface ExternalPagination {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
}
// ===== 외부 선택 상태 설정 =====
export interface ExternalSelection<T> {
selectedItems: Set<string>;
onToggleSelection: (id: string) => void;
onToggleSelectAll: () => void;
getItemId: (item: T) => string;
}
// ===== 컴포넌트 Props =====
@@ -240,6 +347,36 @@ export interface UniversalListPageProps<T> {
initialData?: T[];
/** 초기 총 개수 */
initialTotalCount?: number;
/**
* 외부 페이지네이션 (서버 사이드 페이지네이션용)
* - 설정 시 내부 페이지네이션 무시
* - currentPage, totalPages, onPageChange 등 외부에서 관리
*/
externalPagination?: ExternalPagination;
/**
* 외부 선택 상태 관리 (특수 행 타입이 있는 테이블용)
* - 설정 시 내부 선택 상태 무시
* - 선택 가능한 행만 필터링하는 로직은 외부에서 처리
*/
externalSelection?: ExternalSelection<T>;
/**
* 탭 변경 콜백 (서버 사이드 필터링용)
* - 설정 시 탭 변경 시 외부에 알림
* - 외부에서 API 호출 후 데이터 갱신 가능
*/
onTabChange?: (tab: string) => void;
/**
* 검색어 변경 콜백 (서버 사이드 검색용)
* - 설정 시 검색어 변경 시 외부에 알림
* - 외부에서 API 호출 후 데이터 갱신 가능
*/
onSearchChange?: (search: string) => void;
/**
* 필터 변경 콜백 (서버 사이드 필터링용)
* - 설정 시 필터 변경 시 외부에 알림
* - 외부에서 API 호출 후 데이터 갱신 가능
*/
onFilterChange?: (filters: Record<string, string | string[]>) => void;
}
// ===== 내부 상태 타입 =====