init studio admin frontend
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user