ERP-node/frontend/components/unified/UnifiedList.tsx

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;