codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
에듀›중앙 관리자 플랫폼 — 여러 도메인을 한 허브에서›4단계

4단계

AdminResourceTable 공통 컴포넌트

0회 조회

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

← 3단계

여러 PostgreSQL 풀 연결

5단계 →

OAuth 2 provider + 화이트리스트