556 lines
18 KiB
TypeScript
556 lines
18 KiB
TypeScript
"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<HTMLDivElement, {
|
|
columns: ListColumn[];
|
|
data: Record<string, unknown>[];
|
|
selectedRows: Record<string, unknown>[];
|
|
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
|
onRowClick?: (row: Record<string, unknown>) => 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<string, unknown>) => {
|
|
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<string, unknown>, 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 (
|
|
<div ref={ref} className={cn("border rounded-md overflow-hidden", className)}>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-muted/50">
|
|
{onRowSelect && (
|
|
<TableHead className="w-10">
|
|
<Checkbox
|
|
checked={allSelected}
|
|
// indeterminate 상태는 data-state로 처리
|
|
data-state={someSelected ? "indeterminate" : allSelected ? "checked" : "unchecked"}
|
|
onCheckedChange={handleSelectAll}
|
|
/>
|
|
</TableHead>
|
|
)}
|
|
{columns.map((column) => (
|
|
<TableHead
|
|
key={column.field}
|
|
className={cn(
|
|
column.sortable && "cursor-pointer select-none hover:bg-muted",
|
|
)}
|
|
style={{ width: column.width ? `${column.width}px` : "auto" }}
|
|
onClick={() => column.sortable && onSort?.(column.field)}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
{column.header}
|
|
{column.sortable && sortColumn === column.field && (
|
|
sortDirection === "asc"
|
|
? <ChevronUp className="h-4 w-4" />
|
|
: <ChevronDown className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
</TableHead>
|
|
))}
|
|
{editable && <TableHead className="w-10" />}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={columns.length + (onRowSelect ? 1 : 0) + (editable ? 1 : 0)}
|
|
className="h-24 text-center text-muted-foreground"
|
|
>
|
|
데이터가 없습니다
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
data.map((row, index) => (
|
|
<TableRow
|
|
key={index}
|
|
className={cn(
|
|
"cursor-pointer hover:bg-muted/50",
|
|
isRowSelected(row) && "bg-primary/10"
|
|
)}
|
|
onClick={() => onRowClick?.(row)}
|
|
>
|
|
{onRowSelect && (
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={isRowSelected(row)}
|
|
onCheckedChange={(checked) => handleSelectRow(row, checked as boolean)}
|
|
/>
|
|
</TableCell>
|
|
)}
|
|
{columns.map((column) => (
|
|
<TableCell key={column.field}>
|
|
{formatCellValue(row[column.field], column.format)}
|
|
</TableCell>
|
|
))}
|
|
{editable && (
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
});
|
|
TableView.displayName = "TableView";
|
|
|
|
/**
|
|
* 카드 뷰 컴포넌트
|
|
*/
|
|
const CardView = forwardRef<HTMLDivElement, {
|
|
columns: ListColumn[];
|
|
data: Record<string, unknown>[];
|
|
selectedRows: Record<string, unknown>[];
|
|
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
|
onRowClick?: (row: Record<string, unknown>) => void;
|
|
className?: string;
|
|
}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => {
|
|
const isRowSelected = useCallback((row: Record<string, unknown>) => {
|
|
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
|
|
}, [selectedRows]);
|
|
|
|
const handleCardClick = useCallback((row: Record<string, unknown>) => {
|
|
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 (
|
|
<div ref={ref} className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", className)}>
|
|
{data.length === 0 ? (
|
|
<div className="col-span-full py-12 text-center text-muted-foreground">
|
|
데이터가 없습니다
|
|
</div>
|
|
) : (
|
|
data.map((row, index) => (
|
|
<Card
|
|
key={index}
|
|
className={cn(
|
|
"cursor-pointer transition-colors hover:border-primary",
|
|
isRowSelected(row) && "border-primary bg-primary/5"
|
|
)}
|
|
onClick={() => handleCardClick(row)}
|
|
>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base">
|
|
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<dl className="space-y-1 text-sm">
|
|
{otherColumns.slice(0, 4).map((column) => (
|
|
<div key={column.field} className="flex justify-between">
|
|
<dt className="text-muted-foreground">{column.header}</dt>
|
|
<dd className="font-medium">
|
|
{formatCellValue(row[column.field], column.format)}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
CardView.displayName = "CardView";
|
|
|
|
/**
|
|
* 리스트 뷰 컴포넌트
|
|
*/
|
|
const ListView = forwardRef<HTMLDivElement, {
|
|
columns: ListColumn[];
|
|
data: Record<string, unknown>[];
|
|
selectedRows: Record<string, unknown>[];
|
|
onRowSelect?: (rows: Record<string, unknown>[]) => void;
|
|
onRowClick?: (row: Record<string, unknown>) => void;
|
|
className?: string;
|
|
}>(({ columns, data, selectedRows = [], onRowSelect, onRowClick, className }, ref) => {
|
|
const isRowSelected = useCallback((row: Record<string, unknown>) => {
|
|
return selectedRows.some((r) => r === row || JSON.stringify(r) === JSON.stringify(row));
|
|
}, [selectedRows]);
|
|
|
|
const handleItemClick = useCallback((row: Record<string, unknown>) => {
|
|
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 (
|
|
<div ref={ref} className={cn("divide-y border rounded-md", className)}>
|
|
{data.length === 0 ? (
|
|
<div className="py-12 text-center text-muted-foreground">
|
|
데이터가 없습니다
|
|
</div>
|
|
) : (
|
|
data.map((row, index) => (
|
|
<div
|
|
key={index}
|
|
className={cn(
|
|
"flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50",
|
|
isRowSelected(row) && "bg-primary/10"
|
|
)}
|
|
onClick={() => handleItemClick(row)}
|
|
>
|
|
{onRowSelect && (
|
|
<Checkbox
|
|
checked={isRowSelected(row)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
onRowSelect([...selectedRows, row]);
|
|
} else {
|
|
onRowSelect(selectedRows.filter((r) => r !== row));
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">
|
|
{primaryColumn && formatCellValue(row[primaryColumn.field], primaryColumn.format)}
|
|
</div>
|
|
{secondaryColumn && (
|
|
<div className="text-sm text-muted-foreground truncate">
|
|
{formatCellValue(row[secondaryColumn.field], secondaryColumn.format)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
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<HTMLDivElement, UnifiedListProps>(
|
|
(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<string | undefined>();
|
|
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 <TableView {...viewProps} />;
|
|
case "card":
|
|
return <CardView {...viewProps} />;
|
|
case "list":
|
|
return <ListView {...viewProps} />;
|
|
case "kanban":
|
|
// TODO: 칸반 뷰 구현
|
|
return (
|
|
<div className="p-4 border rounded text-center text-muted-foreground">
|
|
칸반 뷰 (미구현)
|
|
</div>
|
|
);
|
|
default:
|
|
return <TableView {...viewProps} />;
|
|
}
|
|
};
|
|
|
|
const showLabel = label && style?.labelDisplay !== false;
|
|
const showSearch = config.searchable;
|
|
const componentWidth = size?.width || style?.width;
|
|
const componentHeight = size?.height || style?.height;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className="flex flex-col gap-4"
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{/* 헤더 영역 */}
|
|
{(showLabel || showSearch) && (
|
|
<div className="flex items-center justify-between gap-4 flex-shrink-0">
|
|
{showLabel && (
|
|
<Label className="text-lg font-semibold">{label}</Label>
|
|
)}
|
|
|
|
{/* 검색 */}
|
|
{showSearch && (
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder="검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => {
|
|
setSearchTerm(e.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="pl-10 h-9"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 데이터 뷰 */}
|
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
{renderView()}
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
{config.pageable && totalPages > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-muted-foreground">
|
|
총 {sortedData.length}건 중 {(currentPage - 1) * pageSize + 1}-
|
|
{Math.min(currentPage * pageSize, sortedData.length)}건
|
|
</div>
|
|
<Pagination>
|
|
<PaginationContent>
|
|
<PaginationItem>
|
|
<PaginationPrevious
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
/>
|
|
</PaginationItem>
|
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
const page = i + 1;
|
|
return (
|
|
<PaginationItem key={page}>
|
|
<PaginationLink
|
|
onClick={() => setCurrentPage(page)}
|
|
isActive={currentPage === page}
|
|
className="cursor-pointer"
|
|
>
|
|
{page}
|
|
</PaginationLink>
|
|
</PaginationItem>
|
|
);
|
|
})}
|
|
<PaginationItem>
|
|
<PaginationNext
|
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
/>
|
|
</PaginationItem>
|
|
</PaginationContent>
|
|
</Pagination>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
UnifiedList.displayName = "UnifiedList";
|
|
|
|
export default UnifiedList;
|
|
|