177 lines
6.3 KiB
TypeScript
177 lines
6.3 KiB
TypeScript
import { useMemo, useState, type ReactNode } from 'react';
|
|
import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
|
import { Button, Card } from './ui';
|
|
|
|
export type DataTableColumn<T> = {
|
|
key: string;
|
|
header: string;
|
|
className?: string;
|
|
cell: (row: T) => ReactNode;
|
|
};
|
|
|
|
export type DataTableFilter<T> = {
|
|
key: string;
|
|
label: string;
|
|
options: Array<{ label: string; value: string }>;
|
|
getValue: (row: T) => string;
|
|
};
|
|
|
|
export function DataTableCard<T>({
|
|
title,
|
|
description,
|
|
rows,
|
|
columns,
|
|
filters = [],
|
|
searchPlaceholder = '검색',
|
|
getSearchText,
|
|
getRowKey,
|
|
pageSize = 8,
|
|
minWidth = 980,
|
|
loading = false,
|
|
emptyMessage = '데이터가 없습니다.'
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
rows: T[];
|
|
columns: Array<DataTableColumn<T>>;
|
|
filters?: Array<DataTableFilter<T>>;
|
|
searchPlaceholder?: string;
|
|
getSearchText: (row: T) => string;
|
|
getRowKey: (row: T) => string;
|
|
pageSize?: number;
|
|
minWidth?: number;
|
|
loading?: boolean;
|
|
emptyMessage?: string;
|
|
}) {
|
|
const [search, setSearch] = useState('');
|
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
const [page, setPage] = useState(1);
|
|
|
|
const filteredRows = useMemo(() => {
|
|
const normalizedSearch = search.trim().toLowerCase();
|
|
|
|
return rows.filter((row) => {
|
|
const matchesSearch = normalizedSearch
|
|
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
|
: true;
|
|
const matchesFilters = filters.every((filter) => {
|
|
const selected = filterValues[filter.key];
|
|
return !selected || filter.getValue(row) === selected;
|
|
});
|
|
|
|
return matchesSearch && matchesFilters;
|
|
});
|
|
}, [filterValues, filters, getSearchText, rows, search]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
|
const currentPage = Math.min(page, totalPages);
|
|
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
|
|
|
const updateFilter = (key: string, value: string) => {
|
|
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
|
setPage(1);
|
|
};
|
|
|
|
return (
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-sm font-semibold text-foreground">{title}</h2>
|
|
{description ? <p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p> : null}
|
|
</div>
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
<div className="relative w-full max-w-md lg:w-80">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
|
placeholder={searchPlaceholder}
|
|
value={search}
|
|
onChange={(event) => {
|
|
setSearch(event.target.value);
|
|
setPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
{filters.length ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
{filters.map((filter) => (
|
|
<label key={filter.key} className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
{filter.label}
|
|
<select
|
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={filterValues[filter.key] ?? ''}
|
|
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
|
>
|
|
<option value="">전체</option>
|
|
{filter.options.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full text-left text-sm" style={{ minWidth }}>
|
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
<tr>
|
|
{columns.map((column) => (
|
|
<th key={column.key} className={column.className ?? 'px-5 py-3 font-medium'}>
|
|
{column.header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{!loading && pageRows.map((row) => (
|
|
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
|
{columns.map((column) => (
|
|
<td key={column.key} className="px-5 py-3">
|
|
{column.cell(row)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
{!loading && pageRows.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className="h-44 px-5 py-10 text-center text-sm text-muted-foreground">
|
|
{emptyMessage}
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
|
<span className="text-xs text-muted-foreground">
|
|
{loading ? '불러오는 중' : `총 ${filteredRows.length}건 · ${currentPage} / ${totalPages}`}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 px-3 text-[13px]"
|
|
disabled={currentPage <= 1}
|
|
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
이전
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 px-3 text-[13px]"
|
|
disabled={currentPage >= totalPages}
|
|
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
|
>
|
|
다음
|
|
<ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|