Files
studio-admin-fe/src/components/data-table.tsx
T
chyoo 9625752a81
ci/woodpecker/push/woodpecker Pipeline was successful
track requesters for builds releases deployments and migrations
2026-07-10 10:42:56 +09:00

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>
);
}