feat: 품목관리 동적 렌더링 시스템 구현

- DynamicItemForm 컴포넌트 구조 생성
  - DynamicField: 필드 타입별 렌더링
  - DynamicSection: 섹션 단위 렌더링
  - DynamicFormRenderer: 페이지 전체 렌더링
- 필드 타입별 컴포넌트 (TextField, NumberField, DropdownField, CheckboxField, DateField, FileField, CustomField)
- 커스텀 훅 (useDynamicFormState, useFormStructure, useConditionalFields)
- DataTable 공통 컴포넌트 (테이블, 페이지네이션, 검색, 탭필터, 통계카드)
- ItemFormWrapper: Feature Flag 기반 폼 선택
- 타입 정의 및 문서화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-28 20:14:43 +09:00
parent 8fd9cf2d40
commit 6ed5d4ffb3
28 changed files with 5359 additions and 2 deletions

View File

@@ -0,0 +1,550 @@
/**
* DataTable Component
*
* 범용 데이터 테이블 컴포넌트
* - 검색/필터링
* - 정렬
* - 페이지네이션
* - 행 선택
* - 반응형 (데스크톱: 테이블, 모바일: 카드)
*
* @example
* <DataTable
* data={items}
* columns={columns}
* search={{ placeholder: '검색...' }}
* pagination={{ pageSize: 20 }}
* selection={{ enabled: true }}
* rowActions={[
* { key: 'view', icon: Search, onClick: handleView },
* { key: 'edit', icon: Edit, onClick: handleEdit },
* ]}
* />
*/
'use client';
import { useState, useMemo, useCallback } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Loader2, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Pagination } from './Pagination';
import { TabFilter } from './TabFilter';
import { SearchFilter } from './SearchFilter';
import type {
DataTableProps,
BaseDataItem,
SortState,
ColumnDef,
} from './types';
export function DataTable<T extends BaseDataItem>({
data,
columns,
loading = false,
// 검색/필터
search,
tabFilter,
defaultFilterValue = 'all',
// 선택
selection,
onSelectionChange,
// 페이지네이션
pagination = { pageSize: 20 },
// 정렬
defaultSort,
onSortChange,
// 액션
rowActions = [],
bulkActions = [],
// 스타일
striped = false,
hoverable = true,
// 빈 상태
emptyState,
// 기타
onRowClick,
getRowKey = (row) => row.id,
}: DataTableProps<T>) {
// 상태
const [searchTerm, setSearchTerm] = useState('');
const [filterValue, setFilterValue] = useState(defaultFilterValue);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(pagination.pageSize);
const [sort, setSort] = useState<SortState>(defaultSort || { column: null, direction: null });
// 검색/필터/정렬 적용된 데이터
const processedData = useMemo(() => {
let result = [...data];
// 탭 필터 적용
if (tabFilter && filterValue !== 'all') {
result = result.filter((item) => {
const value = item[tabFilter.key];
return value === filterValue;
});
}
// 검색 적용
if (search && searchTerm) {
const searchLower = searchTerm.toLowerCase();
const searchFields = search.searchFields || columns.map((c) => c.key);
result = result.filter((item) =>
searchFields.some((field) => {
const value = item[field];
if (value == null) return false;
return String(value).toLowerCase().includes(searchLower);
})
);
}
// 정렬 적용
if (sort.column && sort.direction) {
result.sort((a, b) => {
const aVal = a[sort.column!];
const bVal = b[sort.column!];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
let comparison = 0;
if (typeof aVal === 'number' && typeof bVal === 'number') {
comparison = aVal - bVal;
} else {
comparison = String(aVal).localeCompare(String(bVal));
}
return sort.direction === 'desc' ? -comparison : comparison;
});
}
return result;
}, [data, tabFilter, filterValue, search, searchTerm, sort, columns]);
// 페이지네이션 적용
const totalPages = Math.ceil(processedData.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedData = processedData.slice(startIndex, endIndex);
// 전체 선택 상태
const selectAll = useMemo(() => {
if (paginatedData.length === 0) return false;
return paginatedData.every((row) => selectedIds.has(getRowKey(row)));
}, [paginatedData, selectedIds, getRowKey]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchTerm(value);
setCurrentPage(1);
}, []);
const handleFilterChange = useCallback((value: string) => {
setFilterValue(value);
setCurrentPage(1);
}, []);
const handleSort = useCallback((column: string) => {
setSort((prev) => {
let newDirection: SortState['direction'] = 'asc';
if (prev.column === column) {
if (prev.direction === 'asc') newDirection = 'desc';
else if (prev.direction === 'desc') newDirection = null;
}
const newSort: SortState = {
column: newDirection ? column : null,
direction: newDirection,
};
onSortChange?.(newSort);
return newSort;
});
}, [onSortChange]);
const handleSelectAll = useCallback(() => {
const newSelected = new Set(selectedIds);
if (selectAll) {
paginatedData.forEach((row) => newSelected.delete(getRowKey(row)));
} else {
paginatedData.forEach((row) => newSelected.add(getRowKey(row)));
}
setSelectedIds(newSelected);
onSelectionChange?.(newSelected);
}, [selectAll, paginatedData, selectedIds, getRowKey, onSelectionChange]);
const handleSelectRow = useCallback((row: T) => {
const key = getRowKey(row);
const newSelected = new Set(selectedIds);
if (selection?.single) {
newSelected.clear();
if (!selectedIds.has(key)) {
newSelected.add(key);
}
} else {
if (newSelected.has(key)) {
newSelected.delete(key);
} else {
newSelected.add(key);
}
}
setSelectedIds(newSelected);
onSelectionChange?.(newSelected);
}, [selectedIds, selection, getRowKey, onSelectionChange]);
const handlePageSizeChange = useCallback((size: number) => {
setPageSize(size);
setCurrentPage(1);
}, []);
// 정렬 아이콘 렌더링
const renderSortIcon = (column: ColumnDef<T>) => {
if (!column.sortable) return null;
if (sort.column !== column.key) {
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
}
if (sort.direction === 'asc') {
return <ArrowUp className="ml-1 h-4 w-4" />;
}
if (sort.direction === 'desc') {
return <ArrowDown className="ml-1 h-4 w-4" />;
}
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
};
// 빈 상태 렌더링
const renderEmptyState = () => {
const isFiltered = searchTerm || filterValue !== 'all';
const title = isFiltered
? emptyState?.filteredTitle || '검색 결과가 없습니다.'
: emptyState?.title || '데이터가 없습니다.';
const description = isFiltered
? emptyState?.filteredDescription
: emptyState?.description;
return (
<div className="text-center py-12 text-muted-foreground">
{emptyState?.icon && (
<emptyState.icon className="w-12 h-12 mx-auto mb-4 opacity-50" />
)}
<p className="text-lg font-medium">{title}</p>
{description && <p className="text-sm mt-1">{description}</p>}
</div>
);
};
// 선택된 항목들
const selectedItems = useMemo(() => {
return data.filter((item) => selectedIds.has(getRowKey(item)));
}, [data, selectedIds, getRowKey]);
// 필터 옵션에 카운트 추가
const filterOptionsWithCount = useMemo(() => {
if (!tabFilter) return [];
return tabFilter.options.map((opt) => ({
...opt,
count: opt.value === 'all'
? data.length
: data.filter((item) => item[tabFilter.key] === opt.value).length,
}));
}, [tabFilter, data]);
return (
<div className="space-y-4">
{/* 검색/필터 */}
{(search || tabFilter) && (
<Card>
<CardContent className="p-4 md:p-6">
<SearchFilter
searchValue={searchTerm}
onSearchChange={handleSearchChange}
searchConfig={search}
filterValue={filterValue}
onFilterChange={tabFilter ? handleFilterChange : undefined}
filterOptions={filterOptionsWithCount}
/>
</CardContent>
</Card>
)}
{/* 벌크 액션 */}
{bulkActions.length > 0 && selectedIds.size > 0 && (
<Card>
<CardContent className="p-3 flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{selectedIds.size}
</span>
<div className="flex-1" />
{bulkActions
.filter((action) => !action.minSelected || selectedIds.size >= action.minSelected)
.map((action) => (
<Button
key={action.key}
variant={action.variant || 'outline'}
size="sm"
onClick={() => action.onClick(selectedItems)}
>
{action.icon && <action.icon className="w-4 h-4 mr-1" />}
{action.label}
</Button>
))}
</CardContent>
</Card>
)}
{/* 메인 테이블 카드 */}
<Card>
<CardHeader>
<CardTitle className="text-sm md:text-base">
{tabFilter
? `${filterOptionsWithCount.find((o) => o.value === filterValue)?.label || ''} 목록`
: '전체 목록'}{' '}
({processedData.length})
</CardTitle>
</CardHeader>
<CardContent className="p-4 md:p-6">
{/* 탭 필터 */}
{tabFilter && (
<div className="mb-6">
<TabFilter
value={filterValue}
onChange={handleFilterChange}
options={filterOptionsWithCount}
/>
</div>
)}
{/* 로딩 상태 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="ml-3 text-muted-foreground"> ...</span>
</div>
) : paginatedData.length === 0 ? (
renderEmptyState()
) : (
<>
{/* 모바일 카드 뷰 */}
<div className="lg:hidden space-y-3">
{paginatedData.map((row, index) => (
<div
key={getRowKey(row)}
className={cn(
'border rounded-lg p-4 space-y-3 bg-card transition-colors',
hoverable && 'hover:bg-muted/50',
onRowClick && 'cursor-pointer'
)}
onClick={() => onRowClick?.(row)}
>
<div className="flex items-start justify-between gap-3">
{selection?.enabled && (
<Checkbox
checked={selectedIds.has(getRowKey(row))}
onCheckedChange={() => handleSelectRow(row)}
onClick={(e) => e.stopPropagation()}
/>
)}
<div className="flex-1 space-y-2">
{columns
.filter((col) => col.renderMobile !== null)
.map((col) => {
const value = row[col.key];
const rendered = col.renderMobile
? col.renderMobile(value, row, index)
: col.render
? col.render(value, row, index)
: value;
if (rendered === null) return null;
return (
<div key={col.key}>
{rendered}
</div>
);
})}
</div>
</div>
{rowActions.length > 0 && (
<div className="flex items-center justify-end gap-1 pt-2 border-t">
{rowActions
.filter((action) => !action.visible || action.visible(row))
.map((action) => (
<Button
key={action.key}
variant={action.variant || 'ghost'}
size="sm"
onClick={(e) => {
e.stopPropagation();
action.onClick(row);
}}
title={action.tooltip}
className="h-8 px-3"
>
{action.icon && <action.icon className="h-4 w-4" />}
{action.label && (
<span className="ml-1 text-xs">{action.label}</span>
)}
</Button>
))}
</div>
)}
</div>
))}
</div>
{/* 데스크톱 테이블 */}
<div className="hidden lg:block rounded-md border">
<Table>
<TableHeader>
<TableRow>
{selection?.enabled && (
<TableHead className="w-[50px]">
{!selection.single && (
<Checkbox
checked={selectAll}
onCheckedChange={handleSelectAll}
/>
)}
</TableHead>
)}
{columns
.filter((col) => !col.hideOnMobile || !col.hideOnTablet)
.map((col) => (
<TableHead
key={col.key}
className={cn(
col.width && `w-[${col.width}]`,
col.minWidth && `min-w-[${col.minWidth}]`,
col.hideOnMobile && 'hidden md:table-cell',
col.hideOnTablet && 'hidden lg:table-cell',
col.sortable && 'cursor-pointer select-none',
col.align === 'center' && 'text-center',
col.align === 'right' && 'text-right'
)}
onClick={() => col.sortable && handleSort(col.key)}
>
<div className={cn(
'flex items-center',
col.align === 'center' && 'justify-center',
col.align === 'right' && 'justify-end'
)}>
{col.header}
{renderSortIcon(col)}
</div>
</TableHead>
))}
{rowActions.length > 0 && (
<TableHead className="text-right min-w-[100px]"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.map((row, index) => (
<TableRow
key={getRowKey(row)}
className={cn(
hoverable && 'hover:bg-muted/50',
striped && index % 2 === 1 && 'bg-muted/25',
onRowClick && 'cursor-pointer'
)}
onClick={() => onRowClick?.(row)}
>
{selection?.enabled && (
<TableCell>
<Checkbox
checked={selectedIds.has(getRowKey(row))}
onCheckedChange={() => handleSelectRow(row)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
)}
{columns
.filter((col) => !col.hideOnMobile || !col.hideOnTablet)
.map((col) => {
const value = row[col.key];
return (
<TableCell
key={col.key}
className={cn(
col.hideOnMobile && 'hidden md:table-cell',
col.hideOnTablet && 'hidden lg:table-cell',
col.align === 'center' && 'text-center',
col.align === 'right' && 'text-right'
)}
>
{col.render ? col.render(value, row, index) : String(value ?? '-')}
</TableCell>
);
})}
{rowActions.length > 0 && (
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{rowActions
.filter((action) => !action.visible || action.visible(row))
.map((action) => (
<Button
key={action.key}
variant={action.variant || 'ghost'}
size="sm"
onClick={(e) => {
e.stopPropagation();
action.onClick(row);
}}
title={action.tooltip || action.label}
>
{action.icon && <action.icon className="w-4 h-4" />}
</Button>
))}
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={processedData.length}
startIndex={startIndex}
endIndex={endIndex}
onPageChange={setCurrentPage}
pageSize={pageSize}
onPageSizeChange={pagination.showPageSizeSelector ? handlePageSizeChange : undefined}
pageSizeOptions={pagination.pageSizeOptions}
/>
</>
)}
</CardContent>
</Card>
</div>
);
}
export default DataTable;

View File

@@ -0,0 +1,143 @@
/**
* Pagination Component
*
* 테이블 페이지네이션 공통 컴포넌트
*/
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { PaginationProps } from './types';
export function Pagination({
currentPage,
totalPages,
totalItems,
startIndex,
endIndex,
onPageChange,
pageSize,
onPageSizeChange,
pageSizeOptions,
}: PaginationProps) {
if (totalItems === 0) return null;
// 표시할 페이지 번호 계산
const getVisiblePages = () => {
const pages: (number | 'ellipsis')[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible + 2) {
// 전체 페이지가 적으면 모두 표시
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// 항상 첫 페이지
pages.push(1);
// 현재 페이지 주변
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
if (start > 2) {
pages.push('ellipsis');
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < totalPages - 1) {
pages.push('ellipsis');
}
// 항상 마지막 페이지
pages.push(totalPages);
}
return pages;
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{totalItems} {startIndex + 1}-{Math.min(endIndex, totalItems)}
</span>
{pageSizeOptions && onPageSizeChange && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="w-[70px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
<span className="hidden sm:inline"></span>
</Button>
<div className="flex items-center gap-1">
{getVisiblePages().map((page, index) => {
if (page === 'ellipsis') {
return (
<span key={`ellipsis-${index}`} className="px-2 text-muted-foreground">
...
</span>
);
}
return (
<Button
key={page}
variant={currentPage === page ? 'default' : 'outline'}
size="sm"
onClick={() => onPageChange(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<span className="hidden sm:inline"></span>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
);
}
export default Pagination;

View File

@@ -0,0 +1,58 @@
/**
* SearchFilter Component
*
* 검색 입력과 드롭다운 필터를 제공하는 공통 컴포넌트
*/
'use client';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { SearchFilterProps } from './types';
export function SearchFilter({
searchValue,
onSearchChange,
searchConfig,
filterValue,
onFilterChange,
filterOptions,
}: SearchFilterProps) {
return (
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={searchConfig?.placeholder || '검색...'}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
{filterOptions && filterOptions.length > 0 && onFilterChange && (
<Select value={filterValue} onValueChange={onFilterChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="필터" />
</SelectTrigger>
<SelectContent>
{filterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{option.count !== undefined && ` (${option.count})`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}
export default SearchFilter;

View File

@@ -0,0 +1,46 @@
/**
* StatCards Component
*
* 통계 카드 그리드 컴포넌트
*/
'use client';
import { Card, CardContent } from '@/components/ui/card';
import type { StatCardsProps } from './types';
export function StatCards({ stats, onStatClick }: StatCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<Card
key={index}
className={onStatClick && stat.filterValue ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}
onClick={() => {
if (onStatClick && stat.filterValue) {
onStatClick(stat.filterValue);
}
}}
>
<CardContent className="p-4 md:p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
{stat.label}
</p>
<p className="text-3xl md:text-4xl font-bold mt-2">{stat.value}</p>
</div>
{stat.icon && (
<stat.icon
className={`w-10 h-10 md:w-12 md:h-12 opacity-15 ${stat.iconColor || ''}`}
/>
)}
</div>
</CardContent>
</Card>
))}
</div>
);
}
export default StatCards;

View File

@@ -0,0 +1,31 @@
/**
* TabFilter Component
*
* 탭 형태의 필터 컴포넌트
*/
'use client';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { TabFilterProps } from './types';
export function TabFilter({ value, onChange, options }: TabFilterProps) {
return (
<Tabs value={value} onValueChange={onChange} className="w-full">
<div className="overflow-x-auto -mx-2 px-2">
<TabsList className="inline-flex w-auto min-w-full md:grid md:w-full md:max-w-2xl" style={{
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`
}}>
{options.map((option) => (
<TabsTrigger key={option.value} value={option.value} className="whitespace-nowrap">
{option.label}
{option.count !== undefined && ` (${option.count})`}
</TabsTrigger>
))}
</TabsList>
</div>
</Tabs>
);
}
export default TabFilter;

View File

@@ -0,0 +1,14 @@
/**
* DataTable 공통 컴포넌트 모듈
*
* 모든 목록 페이지에서 재사용 가능한 테이블 컴포넌트
*/
export { DataTable } from './DataTable';
export { SearchFilter } from './SearchFilter';
export { Pagination } from './Pagination';
export { TabFilter } from './TabFilter';
export { StatCards } from './StatCards';
export * from './types';
export default DataTable;

View File

@@ -0,0 +1,248 @@
/**
* DataTable 공통 컴포넌트 타입 정의
*
* 모든 목록 페이지에서 재사용 가능한 테이블 컴포넌트
*/
import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
// ===== 기본 타입 =====
/** 데이터 항목의 기본 인터페이스 */
export interface BaseDataItem {
id: string;
[key: string]: unknown;
}
/** 컬럼 정렬 방향 */
export type SortDirection = 'asc' | 'desc' | null;
/** 컬럼 정렬 상태 */
export interface SortState {
column: string | null;
direction: SortDirection;
}
// ===== 컬럼 정의 =====
/** 컬럼 정의 인터페이스 */
export interface ColumnDef<T extends BaseDataItem> {
/** 컬럼 키 (데이터 필드명) */
key: string;
/** 컬럼 헤더 텍스트 */
header: string;
/** 컬럼 너비 (CSS 값) */
width?: string;
/** 최소 너비 */
minWidth?: string;
/** 정렬 가능 여부 */
sortable?: boolean;
/** 모바일에서 숨김 여부 */
hideOnMobile?: boolean;
/** 태블릿에서 숨김 여부 */
hideOnTablet?: boolean;
/** 텍스트 정렬 */
align?: 'left' | 'center' | 'right';
/** 셀 렌더링 함수 */
render?: (value: unknown, row: T, index: number) => ReactNode;
/** 모바일 카드에서의 렌더링 (null이면 표시 안함) */
renderMobile?: (value: unknown, row: T, index: number) => ReactNode | null;
}
// ===== 검색/필터 =====
/** 필터 옵션 */
export interface FilterOption {
value: string;
label: string;
count?: number;
}
/** 탭 필터 설정 */
export interface TabFilter {
key: string;
options: FilterOption[];
}
/** 검색 설정 */
export interface SearchConfig {
placeholder?: string;
/** 검색 대상 필드 키 배열 */
searchFields?: string[];
}
// ===== 선택 =====
/** 선택 설정 */
export interface SelectionConfig {
/** 선택 기능 활성화 */
enabled: boolean;
/** 단일 선택 모드 */
single?: boolean;
}
// ===== 페이지네이션 =====
/** 페이지네이션 설정 */
export interface PaginationConfig {
/** 페이지당 항목 수 */
pageSize: number;
/** 페이지 사이즈 옵션 */
pageSizeOptions?: number[];
/** 페이지 사이즈 변경 가능 여부 */
showPageSizeSelector?: boolean;
}
// ===== 액션 버튼 =====
/** 행 액션 정의 */
export interface RowAction<T extends BaseDataItem> {
key: string;
icon?: LucideIcon;
label?: string;
tooltip?: string;
variant?: 'default' | 'ghost' | 'outline' | 'destructive';
/** 표시 조건 */
visible?: (row: T) => boolean;
/** 클릭 핸들러 */
onClick: (row: T) => void;
}
/** 벌크 액션 정의 (선택된 항목에 대한 액션) */
export interface BulkAction<T extends BaseDataItem> {
key: string;
icon?: LucideIcon;
label: string;
variant?: 'default' | 'outline' | 'destructive';
/** 활성화 조건 (선택된 항목 수) */
minSelected?: number;
/** 클릭 핸들러 */
onClick: (selectedItems: T[]) => void;
}
// ===== 통계 카드 =====
/** 통계 카드 항목 */
export interface StatItem {
label: string;
value: number | string;
icon?: LucideIcon;
iconColor?: string;
/** 클릭 시 해당 필터로 이동 */
filterValue?: string;
}
// ===== 빈 상태 =====
/** 빈 상태 설정 */
export interface EmptyStateConfig {
/** 빈 상태 아이콘 */
icon?: LucideIcon;
/** 빈 상태 제목 */
title?: string;
/** 빈 상태 설명 */
description?: string;
/** 필터 적용 시 빈 상태 제목 */
filteredTitle?: string;
/** 필터 적용 시 빈 상태 설명 */
filteredDescription?: string;
}
// ===== 메인 Props =====
/** DataTable 메인 Props */
export interface DataTableProps<T extends BaseDataItem> {
/** 데이터 배열 */
data: T[];
/** 컬럼 정의 */
columns: ColumnDef<T>[];
/** 로딩 상태 */
loading?: boolean;
// === 검색/필터 ===
/** 검색 설정 */
search?: SearchConfig;
/** 탭 필터 설정 */
tabFilter?: TabFilter;
/** 기본 필터 값 */
defaultFilterValue?: string;
// === 선택 ===
/** 선택 설정 */
selection?: SelectionConfig;
/** 선택 변경 핸들러 */
onSelectionChange?: (selectedIds: Set<string>) => void;
// === 페이지네이션 ===
/** 페이지네이션 설정 */
pagination?: PaginationConfig;
// === 정렬 ===
/** 기본 정렬 */
defaultSort?: SortState;
/** 정렬 변경 핸들러 (서버 사이드 정렬용) */
onSortChange?: (sort: SortState) => void;
// === 액션 ===
/** 행 액션 */
rowActions?: RowAction<T>[];
/** 벌크 액션 */
bulkActions?: BulkAction<T>[];
// === 스타일 ===
/** 테이블 최소 높이 */
minHeight?: string;
/** 스트라이프 스타일 */
striped?: boolean;
/** 호버 스타일 */
hoverable?: boolean;
// === 빈 상태 ===
/** 빈 상태 설정 */
emptyState?: EmptyStateConfig;
// === 기타 ===
/** 행 클릭 핸들러 */
onRowClick?: (row: T) => void;
/** 행 키 추출 함수 (기본: row.id) */
getRowKey?: (row: T) => string;
}
// ===== 서브 컴포넌트 Props =====
/** SearchFilter Props */
export interface SearchFilterProps {
searchValue: string;
onSearchChange: (value: string) => void;
searchConfig?: SearchConfig;
filterValue?: string;
onFilterChange?: (value: string) => void;
filterOptions?: FilterOption[];
}
/** Pagination Props */
export interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
startIndex: number;
endIndex: number;
onPageChange: (page: number) => void;
pageSize?: number;
onPageSizeChange?: (size: number) => void;
pageSizeOptions?: number[];
}
/** TabFilter Props */
export interface TabFilterProps {
value: string;
onChange: (value: string) => void;
options: FilterOption[];
}
/** StatCards Props */
export interface StatCardsProps {
stats: StatItem[];
onStatClick?: (filterValue: string) => void;
}