"use client"; /** * UnifiedList * * 통합 리스트 컴포넌트 * - table: 테이블 뷰 * - card: 카드 뷰 * - kanban: 칸반 뷰 * - list: 단순 리스트 뷰 */ import React, { forwardRef, useCallback, useMemo, useState } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import { cn } from "@/lib/utils"; import { UnifiedListProps, ListColumn } from "@/types/unified-components"; import { Search, ChevronUp, ChevronDown, MoreHorizontal, GripVertical } from "lucide-react"; /** * 테이블 뷰 컴포넌트 */ const TableView = forwardRef[]; selectedRows: Record[]; onRowSelect?: (rows: Record[]) => void; onRowClick?: (row: Record) => void; editable?: boolean; sortColumn?: string; sortDirection?: "asc" | "desc"; onSort?: (column: string) => void; className?: string; }>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, editable, sortColumn, sortDirection, onSort, className }, ref) => { // 행 선택 처리 const isRowSelected = useCallback((row: Record) => { return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row)); }, [selectedRows]); const handleSelectAll = useCallback((checked: boolean) => { if (checked) { onRowSelect?.(data); } else { onRowSelect?.([]); } }, [data, onRowSelect]); const handleSelectRow = useCallback((row: Record, checked: boolean) => { if (checked) { onRowSelect?.([...selectedRows, row]); } else { onRowSelect?.(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row))); } }, [selectedRows, onRowSelect]); const allSelected = data.length > 0 && selectedRows.length === data.length; const someSelected = selectedRows.length > 0 && selectedRows.length < data.length; return (
{onRowSelect && ( )} {columns.map((column) => ( column.sortable && onSort?.(column.field)} >
{column.header} {column.sortable && sortColumn === column.field && ( sortDirection === "asc" ? : )}
))} {editable && }
{data.length === 0 ? ( 데이터가 없습니다 ) : ( data.map((row, index) => ( onRowClick?.(row)} > {onRowSelect && ( e.stopPropagation()}> handleSelectRow(row, checked as boolean)} /> )} {columns.map((column) => ( {formatCellValue(row[column.field], column.format)} ))} {editable && ( e.stopPropagation()}> )} )) )}
); }); TableView.displayName = "TableView"; /** * 카드 뷰 컴포넌트 */ const CardView = forwardRef[]; selectedRows: Record[]; onRowSelect?: (rows: Record[]) => void; onRowClick?: (row: Record) => void; className?: string; }>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => { const isRowSelected = useCallback((row: Record) => { return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row)); }, [selectedRows]); const handleCardClick = useCallback((row: Record) => { if (onRowSelect) { const isSelected = isRowSelected(row); if (isSelected) { onRowSelect(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row))); } else { onRowSelect([...selectedRows, row]); } } onRowClick?.(row); }, [selectedRows, isRowSelected, onRowSelect, onRowClick]); // 주요 컬럼 (첫 번째)과 나머지 구분 const [primaryColumn, ...otherColumns] = columns; return (
{data.length === 0 ? (
데이터가 없습니다
) : ( data.map((row, index) => ( handleCardClick(row)} > {primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
{otherColumns.slice(0, 4).map((column) => (
{column.header}
{formatCellValue(row[column.field], column.format)}
))}
)) )}
); }); CardView.displayName = "CardView"; /** * 리스트 뷰 컴포넌트 */ const ListView = forwardRef[]; selectedRows: Record[]; onRowSelect?: (rows: Record[]) => void; onRowClick?: (row: Record) => void; className?: string; }>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => { const isRowSelected = useCallback((row: Record) => { return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row)); }, [selectedRows]); const handleItemClick = useCallback((row: Record) => { if (onRowSelect) { const isSelected = isRowSelected(row); if (isSelected) { onRowSelect(selectedRows.filter((r) => r !== row && JSON.stringify(r) !== JSON.stringify(row))); } else { onRowSelect([...selectedRows, row]); } } onRowClick?.(row); }, [selectedRows, isRowSelected, onRowSelect, onRowClick]); const [primaryColumn, secondaryColumn] = columns; return (
{data.length === 0 ? (
데이터가 없습니다
) : ( data.map((row, index) => (
handleItemClick(row)} > {onRowSelect && ( e.stopPropagation()} onCheckedChange={(checked) => { if (checked) { onRowSelect([...selectedRows, row]); } else { onRowSelect(selectedRows.filter((r) => r !== row)); } }} /> )}
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
{secondaryColumn && (
{formatCellValue(row[secondaryColumn.field], secondaryColumn.format)}
)}
)) )}
); }); ListView.displayName = "ListView"; /** * 셀 값 포맷팅 */ function formatCellValue(value: unknown, format?: string): React.ReactNode { if (value === null || value === undefined) return "-"; if (format) { switch (format) { case "date": return new Date(String(value)).toLocaleDateString("ko-KR"); case "datetime": return new Date(String(value)).toLocaleString("ko-KR"); case "currency": return Number(value).toLocaleString("ko-KR") + "원"; case "number": return Number(value).toLocaleString("ko-KR"); case "percent": return Number(value).toFixed(1) + "%"; default: return String(value); } } return String(value); } /** * 메인 UnifiedList 컴포넌트 */ export const UnifiedList = forwardRef( (props, ref) => { const { id, label, style, size, config: configProp, data = [], selectedRows = [], onRowSelect, onRowClick, } = props; // config가 없으면 기본값 사용 const config = configProp || { viewMode: "table" as const, source: "static" as const, columns: [] }; // 내부 상태 const [searchTerm, setSearchTerm] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [sortColumn, setSortColumn] = useState(); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const pageSize = config.pageSize || 10; const columns = config.columns || []; // 검색 필터링 const filteredData = useMemo(() => { if (!searchTerm || !config.searchable) return data; const term = searchTerm.toLowerCase(); return data.filter((row) => columns.some((col) => { const value = row[col.field]; return value && String(value).toLowerCase().includes(term); }) ); }, [data, searchTerm, config.searchable, columns]); // 정렬 const sortedData = useMemo(() => { if (!sortColumn) return filteredData; return [...filteredData].sort((a, b) => { const aVal = a[sortColumn]; const bVal = b[sortColumn]; if (aVal === bVal) return 0; if (aVal === null || aVal === undefined) return 1; if (bVal === null || bVal === undefined) return -1; const comparison = String(aVal).localeCompare(String(bVal), "ko-KR", { numeric: true }); return sortDirection === "asc" ? comparison : -comparison; }); }, [filteredData, sortColumn, sortDirection]); // 페이지네이션 const paginatedData = useMemo(() => { if (!config.pageable) return sortedData; const start = (currentPage - 1) * pageSize; return sortedData.slice(start, start + pageSize); }, [sortedData, currentPage, pageSize, config.pageable]); const totalPages = Math.ceil(sortedData.length / pageSize); // 정렬 핸들러 const handleSort = useCallback((column: string) => { if (sortColumn === column) { setSortDirection((d) => (d === "asc" ? "desc" : "asc")); } else { setSortColumn(column); setSortDirection("asc"); } }, [sortColumn]); // 뷰모드별 렌더링 const renderView = () => { const viewProps = { columns, data: paginatedData, selectedRows, onRowSelect, onRowClick, editable: config.editable, sortColumn, sortDirection, onSort: handleSort, }; switch (config.viewMode) { case "table": return ; case "card": return ; case "list": return ; case "kanban": // TODO: 칸반 뷰 구현 return (
칸반 뷰 (미구현)
); default: return ; } }; const showLabel = label && style?.labelDisplay !== false; const showSearch = config.searchable; const componentWidth = size?.width || style?.width; const componentHeight = size?.height || style?.height; return (
{/* 헤더 영역 */} {(showLabel || showSearch) && (
{showLabel && ( )} {/* 검색 */} {showSearch && (
{ setSearchTerm(e.target.value); setCurrentPage(1); }} className="pl-10 h-9" />
)}
)} {/* 데이터 뷰 */}
{renderView()}
{/* 페이지네이션 */} {config.pageable && totalPages > 1 && (
총 {sortedData.length}건 중 {(currentPage - 1) * pageSize + 1}- {Math.min(currentPage * pageSize, sortedData.length)}건
setCurrentPage((p) => Math.max(1, p - 1))} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} /> {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { const page = i + 1; return ( setCurrentPage(page)} isActive={currentPage === page} className="cursor-pointer" > {page} ); })} setCurrentPage((p) => Math.min(totalPages, p + 1))} className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} />
)}
); } ); UnifiedList.displayName = "UnifiedList"; export default UnifiedList;