4단계
AdminResourceTable 공통 컴포넌트
35 분
AdminResourceTable 공통 컴포넌트
첫 3 ~ 5 개 페이지는 페이지마다 테이블을 짜도 괜찮지만 10 개를 넘으면 공통 컴포넌트로 뽑는 쪽이 확실히 이득입니다. title · icon · columns · rows · pagination 만 넘기면 30 ~ 70 줄로 한 페이지가 완성.
1. Props 설계
// src/shared/components/AdminResourceTable.tsx
'use client';
import { ReactNode } from 'react';
export interface ResourceTableProps<Row> {
title: string;
subtitle?: string;
icon: ReactNode;
iconColor?: 'blue' | 'green' | 'amber' | 'rose' | 'violet';
actions?: ReactNode;
search?: {
placeholder: string;
value: string;
onChange: (v: string) => void;
};
filters?: ReactNode;
columns: Array<{
header: string;
key: string;
render?: (row: Row) => ReactNode;
className?: string;
}>;
rows: Row[];
rowKey: (row: Row) => string | number;
emptyState?: { title: string; description?: string; action?: ReactNode };
pagination?: {
page: number;
pageSize: number;
total: number;
onPageChange: (p: number) => void;
};
}
2. 컴포넌트 본체
const colorMap = {
blue: { bg: 'bg-blue-50', fg: 'text-blue-600' },
green: { bg: 'bg-green-50', fg: 'text-green-600' },
amber: { bg: 'bg-amber-50', fg: 'text-amber-600' },
rose: { bg: 'bg-rose-50', fg: 'text-rose-600' },
violet: { bg: 'bg-violet-50', fg: 'text-violet-600' },
} as const;
export function AdminResourceTable<Row>({
title, subtitle, icon, iconColor = 'blue', actions,
search, filters, columns, rows, rowKey, emptyState, pagination,
}: ResourceTableProps<Row>) {
const c = colorMap[iconColor];
return (
<div className="space-y-6">
<header className="flex items-center gap-4 rounded-3xl bg-white p-6 shadow-sm">
<div className={`flex h-14 w-14 items-center justify-center rounded-2xl ${c.bg} ${c.fg}`}>
{icon}
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold">{title}</h1>
{subtitle && <p className="text-sm text-slate-500">{subtitle}</p>}
</div>
{actions}
</header>
{(search || filters) && (
<div className="flex gap-2">
{search && (
<input
className="flex-1 rounded-lg border p-2"
placeholder={search.placeholder}
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
/>
)}
{filters}
</div>
)}
{rows.length === 0 ? (
<EmptyState {...emptyState} />
) : (
<table className="w-full">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} scope="col" className={col.className}>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((col) => (
<td key={col.key} className={col.className}>
{col.render
? col.render(row)
: String((row as Record<string, unknown>)[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
{pagination && <Pagination {...pagination} />}
</div>
);
}
EmptyState · Pagination 은 각자 작은 컴포넌트로 분리.
3. 사용 예 — 블로그 포스트 목록
// src/app/admin/blog/posts/PostsView.tsx
'use client';
import { AdminResourceTable } from '@/shared/components/AdminResourceTable';
import { FileText } from 'lucide-react';
import { useState } from 'react';
export function PostsView({ initialRows }: { initialRows: Post[] }) {
const [q, setQ] = useState('');
const rows = initialRows.filter((p) =>
p.title.toLowerCase().includes(q.toLowerCase())
);
return (
<AdminResourceTable<Post>
title="블로그 포스트"
subtitle={`총 ${rows.length} 건`}
icon={<FileText className="h-7 w-7" />}
iconColor="blue"
search={{ placeholder: '제목 검색', value: q, onChange: setQ }}
columns={[
{ header: '제목', key: 'title' },
{ header: '게시됨', key: 'published', render: (p) => p.published ? '✓' : '—' },
{ header: '작성일', key: 'created_at', render: (p) => p.created_at.slice(0, 10) },
]}
rows={rows}
rowKey={(p) => p.id}
emptyState={{ title: '포스트가 없습니다', description: '첫 글을 작성해 보세요' }}
/>
);
}
한 화면 분량.
4. URL-first 검색 · 페이지네이션
위 예는 useState 로 단순화했으나, 실제로는 URL 에 실으면 새로고침 · 공유에 안전.
import { useRouter, useSearchParams } from 'next/navigation';
const params = useSearchParams();
const router = useRouter();
const q = params.get('q') ?? '';
const page = Number(params.get('page') ?? 1);
const onQChange = (v: string) => {
const u = new URLSearchParams(params);
if (v) u.set('q', v); else u.delete('q');
u.delete('page'); // 검색 변경 시 1 페이지로
router.push(`?${u.toString()}`);
};
Server Component 에서 searchParams 로 받아 DB 쿼리에 반영.
5. 반응형 · 접근성
- 모바일은 대부분 관리자 사용 패턴에서 빈도가 낮음. 최소
overflow-x-auto로 가로 스크롤만 허용 <th scope="col">,<th scope="row">로 스크린 리더가 구조 이해- 빈 상태 메시지는 테이블 내부
<tr><td colSpan>으로 두는 편이 스크린 리더 친화적
하고픈 말
공용 테이블 컴포넌트는 초반에 과하게 느껴지지만 3 ~ 5 개 페이지를 넘으면 실질 가치가 드러납니다. 처음에는 최소 props (title · columns · rows · pagination) 로 시작해 실제 니즈가 생길 때마다 하나씩 확장하는 게 유지 비용이 낮습니다.
Next
- 05-oauth-allowlist