feat(WEB): UniversalListPage 컴포넌트 및 파일럿 마이그레이션
- UniversalListPage 템플릿 컴포넌트 생성 - 카드관리(HR) 파일럿 마이그레이션 (기본 케이스) - 게시판목록 파일럿 마이그레이션 (동적 탭 fetchTabs) - 발주관리 파일럿 마이그레이션 (ScheduleCalendar beforeTableContent) - 클라이언트 사이드 필터링 지원 (customFilterFn, customSortFn) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
525
src/components/templates/UniversalListPage/index.tsx
Normal file
525
src/components/templates/UniversalListPage/index.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
'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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type PaginationConfig,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import type {
|
||||
UniversalListPageProps,
|
||||
TabOption,
|
||||
FilterValues,
|
||||
} from './types';
|
||||
|
||||
export function UniversalListPage<T>({
|
||||
config,
|
||||
initialData,
|
||||
initialTotalCount,
|
||||
}: 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;
|
||||
|
||||
// ===== 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]);
|
||||
|
||||
// ===== 데이터 로딩 =====
|
||||
const fetchData = useCallback(async () => {
|
||||
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);
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
toast.error(result.error || '데이터를 불러오는데 실패했습니다.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[UniversalListPage] Fetch error:', error);
|
||||
toast.error('데이터를 불러오는데 실패했습니다.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]);
|
||||
|
||||
// 초기 로딩
|
||||
useEffect(() => {
|
||||
if (!initialData) {
|
||||
fetchData();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침
|
||||
useEffect(() => {
|
||||
if (!config.clientSideFiltering && !isLoading) {
|
||||
fetchData();
|
||||
}
|
||||
}, [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 toggleSelection = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
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]);
|
||||
|
||||
// ===== 행 클릭 핸들러 =====
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
[config.basePath, config.detailMode, getItemId, locale, router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(item: T) => {
|
||||
const id = getItemId(item);
|
||||
router.push(`/${locale}${config.basePath}/${id}/edit`);
|
||||
},
|
||||
[config.basePath, getItemId, locale, router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push(`/${locale}${config.basePath}/new`);
|
||||
}, [config.basePath, locale, router]);
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDeleteClick = useCallback((item: T) => {
|
||||
setItemToDelete(item);
|
||||
setIsBulkDelete(false);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setIsBulkDelete(true);
|
||||
setDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
try {
|
||||
if (isBulkDelete) {
|
||||
if (config.actions.deleteBulk) {
|
||||
const result = await config.actions.deleteBulk(Array.from(selectedItems));
|
||||
if (result.success) {
|
||||
toast.success(`${selectedItems.size}건이 삭제되었습니다.`);
|
||||
// 클라이언트 사이드: 로컬 데이터에서 제거
|
||||
if (config.clientSideFiltering) {
|
||||
setRawData((prev) =>
|
||||
prev.filter((item) => !selectedItems.has(getItemId(item)))
|
||||
);
|
||||
} else {
|
||||
fetchData();
|
||||
}
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} else if (config.actions.deleteItem) {
|
||||
const ids = Array.from(selectedItems);
|
||||
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) => !selectedItems.has(getItemId(item)))
|
||||
);
|
||||
} else {
|
||||
fetchData();
|
||||
}
|
||||
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, fetchData, getItemId, isBulkDelete, itemToDelete, selectedItems]);
|
||||
|
||||
// ===== 검색 핸들러 =====
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
}, []);
|
||||
|
||||
// ===== 필터 핸들러 =====
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
}, []);
|
||||
|
||||
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());
|
||||
}, []);
|
||||
|
||||
// ===== 페이지네이션 핸들러 =====
|
||||
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]);
|
||||
|
||||
// ===== 페이지네이션 config =====
|
||||
const paginationConfig: PaginationConfig = useMemo(
|
||||
() => ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: handlePageChange,
|
||||
}),
|
||||
[currentPage, totalPages, totalCount, itemsPerPage, handlePageChange]
|
||||
);
|
||||
|
||||
// ===== 렌더링 함수 래퍼 =====
|
||||
const renderTableRow = useCallback(
|
||||
(item: T, index: number, globalIndex: number) => {
|
||||
const id = getItemId(item);
|
||||
const isSelected = selectedItems.has(id);
|
||||
return config.renderTableRow(item, index, globalIndex, {
|
||||
isSelected,
|
||||
onToggle: () => toggleSelection(id),
|
||||
onRowClick: () => handleRowClick(item),
|
||||
onEdit: () => handleEdit(item),
|
||||
onDelete: () => handleDeleteClick(item),
|
||||
});
|
||||
},
|
||||
[config, getItemId, handleDeleteClick, handleEdit, handleRowClick, selectedItems, 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
|
||||
? `선택한 ${selectedItems.size}건을 삭제하시겠습니까?`
|
||||
: '이 항목을 삭제하시겠습니까?');
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2<T>
|
||||
// 페이지 헤더
|
||||
title={config.title}
|
||||
description={config.description}
|
||||
icon={config.icon}
|
||||
headerActions={config.headerActions?.({ onCreate: handleCreate })}
|
||||
// 탭 콘텐츠
|
||||
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}
|
||||
// 테이블 앞 콘텐츠
|
||||
beforeTableContent={config.beforeTableContent}
|
||||
// 테이블 헤더 액션
|
||||
tableHeaderActions={config.tableHeaderActions}
|
||||
// 테이블 컬럼
|
||||
tableColumns={config.columns}
|
||||
// 테이블 푸터
|
||||
tableFooter={config.tableFooter}
|
||||
// 데이터
|
||||
data={displayData}
|
||||
totalCount={totalCount}
|
||||
allData={config.clientSideFiltering ? filteredData : undefined}
|
||||
// 체크박스 선택
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={getItemId}
|
||||
onBulkDelete={config.actions.deleteItem ? handleBulkDeleteClick : undefined}
|
||||
// 표시 옵션
|
||||
showCheckbox={config.showCheckbox}
|
||||
showRowNumber={config.showRowNumber}
|
||||
// 렌더링 함수
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
// 페이지네이션
|
||||
pagination={paginationConfig}
|
||||
// 로딩 상태
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{deleteConfirmTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{deleteConfirmDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 상세 모달 (detailMode === 'modal'일 때) */}
|
||||
{config.detailMode === 'modal' && config.DetailModalComponent && (
|
||||
<config.DetailModalComponent
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
item={selectedItem}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 타입 re-export
|
||||
export * from './types';
|
||||
257
src/components/templates/UniversalListPage/types.ts
Normal file
257
src/components/templates/UniversalListPage/types.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* UniversalListPage 타입 정의
|
||||
*
|
||||
* 59개 리스트 페이지를 통합하기 위한 config 기반 타입 시스템
|
||||
* 기존 기능 100% 유지, 테이블 영역만 공통화
|
||||
*/
|
||||
|
||||
import { ReactNode, RefObject } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import type { FilterFieldConfig, FilterValues } from '@/components/molecules/MobileFilter';
|
||||
|
||||
// ===== 기본 타입 (IntegratedListTemplateV2에서 re-export) =====
|
||||
export type { FilterFieldConfig, FilterValues };
|
||||
|
||||
export interface TabOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface TableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
hideOnMobile?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
}
|
||||
|
||||
export interface StatCard {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
iconColor: string;
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
export interface ListResult<T> {
|
||||
success: boolean;
|
||||
data?: T[];
|
||||
totalCount?: number;
|
||||
totalPages?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DeleteResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ===== 액션 설정 =====
|
||||
export interface ListActions<T> {
|
||||
/** 목록 조회 API */
|
||||
getList: (params?: ListParams) => Promise<ListResult<T>>;
|
||||
/** 단일 삭제 API (선택) */
|
||||
deleteItem?: (id: string) => Promise<DeleteResult>;
|
||||
/** 일괄 삭제 API (선택) */
|
||||
deleteBulk?: (ids: string[]) => Promise<DeleteResult>;
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
filters?: Record<string, string | string[]>;
|
||||
tab?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ===== 상세 보기 모드 =====
|
||||
export type DetailMode = 'page' | 'modal' | 'none';
|
||||
|
||||
// ===== 커스텀 액션 버튼 =====
|
||||
export interface CustomAction<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
/** 단일 아이템에 대한 액션 */
|
||||
onClick?: (item: T) => void | Promise<void>;
|
||||
/** 선택된 아이템들에 대한 일괄 액션 */
|
||||
onBulkClick?: (items: T[]) => void | Promise<void>;
|
||||
/** 액션 표시 조건 */
|
||||
showWhen?: 'always' | 'selected' | 'single-selected' | 'multi-selected';
|
||||
/** 확인 다이얼로그 표시 여부 */
|
||||
confirmDialog?: {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 선택 핸들러 (renderTableRow, renderMobileCard에서 사용) =====
|
||||
export interface SelectionHandlers {
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// ===== 행 클릭 핸들러 =====
|
||||
export interface RowClickHandlers<T> {
|
||||
onRowClick: (item: T) => void;
|
||||
onEdit?: (item: T) => void;
|
||||
onDelete?: (item: T) => void;
|
||||
}
|
||||
|
||||
// ===== 메인 Config 타입 =====
|
||||
export interface UniversalListConfig<T> {
|
||||
// ===== 페이지 기본 정보 =====
|
||||
/** 페이지 제목 */
|
||||
title: string;
|
||||
/** 페이지 설명 (선택) */
|
||||
description?: string;
|
||||
/** 페이지 아이콘 (선택) */
|
||||
icon?: LucideIcon;
|
||||
/** 기본 경로 (예: '/hr/employee-management') */
|
||||
basePath: string;
|
||||
|
||||
// ===== ID 추출 =====
|
||||
/** 아이템에서 ID 추출 (string 키 또는 함수) */
|
||||
idField: keyof T | ((item: T) => string);
|
||||
|
||||
// ===== API 액션 =====
|
||||
actions: ListActions<T>;
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
columns: TableColumn[];
|
||||
|
||||
// ===== 필터 설정 =====
|
||||
/** 필터 필드 설정 */
|
||||
filterConfig?: FilterFieldConfig[];
|
||||
/** 필터 초기값 */
|
||||
initialFilters?: Record<string, string | string[]>;
|
||||
/** 필터 바텀시트 제목 (모바일) */
|
||||
filterTitle?: string;
|
||||
|
||||
// ===== 탭 설정 =====
|
||||
/** 고정 탭 목록 */
|
||||
tabs?: TabOption[];
|
||||
/** 동적 탭 (API에서 가져오기) */
|
||||
fetchTabs?: () => Promise<TabOption[]>;
|
||||
/** 기본 활성 탭 */
|
||||
defaultTab?: string;
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
/** 고정 통계 카드 */
|
||||
stats?: StatCard[];
|
||||
/** 동적 통계 (데이터 기반 계산) */
|
||||
computeStats?: (data: T[], totalCount: number) => StatCard[];
|
||||
|
||||
// ===== 렌더링 함수 =====
|
||||
/** 테이블 행 렌더링 */
|
||||
renderTableRow: (
|
||||
item: T,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<T>
|
||||
) => ReactNode;
|
||||
/** 모바일 카드 렌더링 */
|
||||
renderMobileCard: (
|
||||
item: T,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<T>
|
||||
) => ReactNode;
|
||||
|
||||
// ===== 상세 보기 설정 =====
|
||||
/** 상세 보기 모드 (기본: 'page') */
|
||||
detailMode?: DetailMode;
|
||||
/** 모달 모드일 때 사용할 모달 컴포넌트 */
|
||||
DetailModalComponent?: React.ComponentType<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
item: T | null;
|
||||
onRefresh?: () => void;
|
||||
}>;
|
||||
|
||||
// ===== 커스텀 액션 =====
|
||||
/** 헤더 액션 (등록 버튼 등) */
|
||||
headerActions?: (params: { onCreate?: () => void }) => ReactNode;
|
||||
/** 커스텀 액션 버튼 (상신, 승인 등) */
|
||||
customActions?: CustomAction<T>[];
|
||||
|
||||
// ===== 추가 옵션 =====
|
||||
/** 검색 플레이스홀더 */
|
||||
searchPlaceholder?: string;
|
||||
/** 검색창 숨김 */
|
||||
hideSearch?: boolean;
|
||||
/** 체크박스 표시 여부 (기본: true) */
|
||||
showCheckbox?: boolean;
|
||||
/** 번호 컬럼 표시 여부 (기본: true) */
|
||||
showRowNumber?: boolean;
|
||||
/** 페이지당 항목 수 (기본: 20) */
|
||||
itemsPerPage?: number;
|
||||
/** 삭제 확인 메시지 */
|
||||
deleteConfirmMessage?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
// ===== 클라이언트 사이드 필터링 =====
|
||||
/**
|
||||
* 클라이언트 사이드 필터링 모드 (기본: false)
|
||||
* true인 경우 getList가 전체 데이터를 반환하고, 컴포넌트 내부에서 필터링/페이지네이션 처리
|
||||
*/
|
||||
clientSideFiltering?: boolean;
|
||||
/** 클라이언트 사이드 검색 필터 함수 */
|
||||
searchFilter?: (item: T, searchValue: string) => boolean;
|
||||
/** 클라이언트 사이드 탭 필터 함수 */
|
||||
tabFilter?: (item: T, activeTab: string) => boolean;
|
||||
/** 커스텀 필터 함수 (filterConfig 기반 복잡한 필터링) */
|
||||
customFilterFn?: (items: T[], filterValues: Record<string, string | string[]>) => T[];
|
||||
/** 커스텀 정렬 함수 */
|
||||
customSortFn?: (items: T[], filterValues: Record<string, string | string[]>) => T[];
|
||||
|
||||
// ===== 테이블 헤더 액션 =====
|
||||
/** 테이블 헤더 우측 추가 액션 (총건, 필터 해제 버튼 등) */
|
||||
tableHeaderActions?: ReactNode;
|
||||
|
||||
// ===== 추가 슬롯 =====
|
||||
/** 테이블 앞 커스텀 콘텐츠 */
|
||||
beforeTableContent?: ReactNode;
|
||||
/** 테이블 하단 푸터 */
|
||||
tableFooter?: ReactNode;
|
||||
/** 경고 배너 */
|
||||
alertBanner?: ReactNode;
|
||||
/** 헤더 액션 영역 아래, 검색 위 커스텀 탭 */
|
||||
tabsContent?: ReactNode;
|
||||
/** 추가 필터 (Select, DatePicker 등) */
|
||||
extraFilters?: ReactNode;
|
||||
}
|
||||
|
||||
// ===== 컴포넌트 Props =====
|
||||
export interface UniversalListPageProps<T> {
|
||||
config: UniversalListConfig<T>;
|
||||
/** 초기 데이터 (SSR용, 선택) */
|
||||
initialData?: T[];
|
||||
/** 초기 총 개수 */
|
||||
initialTotalCount?: number;
|
||||
}
|
||||
|
||||
// ===== 내부 상태 타입 =====
|
||||
export interface ListState<T> {
|
||||
data: T[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
isLoading: boolean;
|
||||
searchValue: string;
|
||||
selectedItems: Set<string>;
|
||||
activeTab: string;
|
||||
filters: Record<string, string | string[]>;
|
||||
tabs: TabOption[];
|
||||
}
|
||||
@@ -8,4 +8,21 @@ export type {
|
||||
VersionHistoryItem,
|
||||
DevMetadata,
|
||||
IntegratedListTemplateV2Props,
|
||||
} from "./IntegratedListTemplateV2";
|
||||
} from "./IntegratedListTemplateV2";
|
||||
|
||||
// UniversalListPage - 통합 리스트 페이지 컴포넌트
|
||||
export { UniversalListPage } from "./UniversalListPage";
|
||||
export type {
|
||||
UniversalListConfig,
|
||||
UniversalListPageProps,
|
||||
ListActions,
|
||||
ListParams,
|
||||
ListResult,
|
||||
DeleteResult,
|
||||
CustomAction,
|
||||
DetailMode,
|
||||
SelectionHandlers,
|
||||
RowClickHandlers,
|
||||
FilterFieldConfig,
|
||||
FilterValues,
|
||||
} from "./UniversalListPage";
|
||||
Reference in New Issue
Block a user