239 lines
9.0 KiB
TypeScript
239 lines
9.0 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;
|
|
width?: number;
|
|
cell: (row: T) => ReactNode;
|
|
};
|
|
|
|
export type DataTableFilter<T> = {
|
|
key: string;
|
|
label: string;
|
|
options: Array<{ label: string; value: string }>;
|
|
getValue: (row: T) => string;
|
|
};
|
|
|
|
const spacingWidthPattern = /\bw-(?:\[(\d+)px\]|(\d+))\b/;
|
|
const widthClassPattern = /\bw-(?:\[[^\]]+\]|\d+)\b/g;
|
|
|
|
function getColumnWidth<T>(column: DataTableColumn<T>) {
|
|
if (column.key === 'no') return 75;
|
|
if (column.width) return column.width;
|
|
|
|
const widthMatch = column.className?.match(spacingWidthPattern);
|
|
if (widthMatch?.[1]) return Number(widthMatch[1]);
|
|
if (widthMatch?.[2]) return Number(widthMatch[2]) * 4;
|
|
if (column.key === 'actions') return 80;
|
|
|
|
return 160;
|
|
}
|
|
|
|
function stripWidthClass(className?: string) {
|
|
return className?.replace(widthClassPattern, '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
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 flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
|
|
|
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 table-fixed text-left text-sm" style={{ minWidth }}>
|
|
<colgroup>
|
|
{columns.map((column, columnIndex) => (
|
|
<col
|
|
key={column.key}
|
|
style={
|
|
columnIndex === flexibleColumnIndex
|
|
? { width: '100%' }
|
|
: { width: getColumnWidth(column) }
|
|
}
|
|
/>
|
|
))}
|
|
</colgroup>
|
|
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
|
<tr>
|
|
{columns.map((column, columnIndex) => (
|
|
<th
|
|
key={column.key}
|
|
className={
|
|
column.key === 'no'
|
|
? 'py-3 text-center font-medium'
|
|
: columnIndex === flexibleColumnIndex
|
|
? stripWidthClass(column.className) || 'px-5 py-3 font-medium'
|
|
: column.className ?? 'px-5 py-3 font-medium'
|
|
}
|
|
style={{
|
|
...(column.key === 'no' ? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 } : columnIndex === flexibleColumnIndex ? { width: '100%' } : { width: getColumnWidth(column) }),
|
|
...(columnIndex === flexibleColumnIndex ? { paddingLeft: 12 } : {}),
|
|
}}
|
|
>
|
|
{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, columnIndex) => (
|
|
<td
|
|
key={column.key}
|
|
className={column.key === 'no' ? 'py-3 text-center font-medium' : columns[0]?.key === 'no' && columnIndex === 1 ? 'py-3 pl-3 pr-5' : 'px-5 py-3'}
|
|
style={
|
|
column.key === 'no'
|
|
? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 }
|
|
: columnIndex === flexibleColumnIndex
|
|
? { width: '100%' }
|
|
: { width: getColumnWidth(column) }
|
|
}
|
|
>
|
|
{column.key === 'no' ? (
|
|
<span className="block w-full text-center font-semibold">
|
|
{String((row as { no?: unknown }).no ?? '')}
|
|
</span>
|
|
) : (
|
|
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>
|
|
);
|
|
}
|