init studio admin frontend

This commit is contained in:
2026-07-01 17:34:15 +09:00
commit c50bd2b941
64 changed files with 22452 additions and 0 deletions
+176
View File
@@ -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>
);
}