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:
550
src/components/common/DataTable/DataTable.tsx
Normal file
550
src/components/common/DataTable/DataTable.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user