پرتوپرتو

صفحه جدول داده

الگوی ساخت صفحه لیست با جستجو، فیلتر، مرتب‌سازی، و صفحه‌بندی

معرفی

صفحه جدول داده رایج‌ترین نوع صفحه در اپلیکیشن‌های SaaS است. لیست اینفلوئنسرها، کمپین‌ها، گزارش‌ها، پست‌ها، و هر مجموعه داده‌ای که کاربر باید مرور، جستجو، فیلتر، و مرتب‌سازی کند در این قالب قرار می‌گیرد.

این الگو ترکیب کامپوننت‌های زیر را نشان می‌دهد:

  • ساختار صفحه: PageContainer و PageHeader برای چیدمان یکنواخت
  • نوار ابزار: SearchInput و FilterChip برای جستجو و فیلتر
  • جدول: Table با ستون‌های قابل مرتب‌سازی و کامپوننت‌های دامنه‌ای
  • صفحه‌بندی: PaginationControlled برای ناوبری بین صفحات
  • حالت‌های خالی و بارگذاری: تجربه کاربری کامل در همه شرایط

نمونه بصری

نامدنبال‌کنندگاننرخ تعامل
علی محمدی۱۲٬۵۰۰۴.۲٪
سارا احمدی۸۷٬۳۰۰۳.۱٪
رضا کریمی۲۳۴٬۰۰۰۲.۸٪
مریم حسینی۵٬۲۰۰۶.۷٪
امیر رضایی۴۵٬۸۰۰۳.۵٪

ساختار صفحه

'use client'

import { useState, useMemo, useCallback } from 'react'
import {
  Button,
  SearchInput,
  FilterChip,
  FilterChipGroup,
  Select,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectItem,
  Table,
  TableHeader,
  TableBody,
  TableHead,
  TableRow,
  TableCell,
  TableSortHeader,
  Avatar,
  AvatarImage,
  AvatarFallback,
  Badge,
  SocialPlatformBadge,
  EngagementRateBar,
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  Empty,
  EmptyIcon,
  EmptyTitle,
  EmptyDescription,
  Skeleton,
  PaginationControlled,
} from '@parto-system-design/ui'
import { MoreHorizontal, Plus, Users, Search } from 'lucide-react'

export function InfluencerListPage() {
  return (
    <PageContainer size="large">
      <PageHeader size="large">
        <PageHeaderTitle>اینفلوئنسرها</PageHeaderTitle>
        <PageHeaderAside>
          <Button size="sm">
            <Plus className="h-4 w-4" />
            افزودن اینفلوئنسر
          </Button>
        </PageHeaderAside>
      </PageHeader>

      {/* نوار ابزار: جستجو + فیلترها */}
      <InfluencerToolbar />

      {/* جدول داده */}
      <InfluencerTable />

      {/* صفحه‌بندی */}
      <div className="flex items-center justify-between pt-4">
        <span className="text-sm text-muted-foreground">۲۴ اینفلوئنسر</span>
        <PaginationControlled currentPage={1} totalPages={3} onPageChange={(page) => console.log(page)} />
      </div>
    </PageContainer>
  )
}

الگوی نوار ابزار

نوار ابزار شامل فیلد جستجو، فیلترهای فعال، و گزینه‌های مرتب‌سازی است. از flex-wrap استفاده کنید تا در صفحات کوچک‌تر به خوبی شکسته شود.

function InfluencerToolbar() {
  const [search, setSearch] = useState('')
  const [activeFilters, setActiveFilters] = useState([
    { id: 'platform', label: 'اینستاگرام', variant: 'default' as const },
    { id: 'engagement', label: 'نرخ تعامل بالا', variant: 'brand' as const },
  ])

  function removeFilter(id: string) {
    setActiveFilters((prev) => prev.filter((f) => f.id !== id))
  }

  return (
    <div className="flex flex-wrap items-center gap-3">
      <SearchInput
        placeholder="جستجوی اینفلوئنسر..."
        className="w-[280px]"
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        onClear={() => setSearch('')}
      />
      <FilterChipGroup>
        {activeFilters.map((filter) => (
          <FilterChip
            key={filter.id}
            label={filter.label}
            variant={filter.variant}
            onRemove={() => removeFilter(filter.id)}
          />
        ))}
      </FilterChipGroup>
      <Select defaultValue="newest">
        <SelectTrigger className="w-[160px]" size="sm">
          <SelectValue placeholder="مرتب‌سازی" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="newest">جدیدترین</SelectItem>
          <SelectItem value="followers">بیشترین فالوور</SelectItem>
          <SelectItem value="engagement">بیشترین تعامل</SelectItem>
        </SelectContent>
      </Select>
    </div>
  )
}

جدول با کامپوننت‌های دامنه‌ای

هر ردیف جدول از کامپوننت‌های تخصصی طراحی‌شده برای حوزه رصد اجتماعی استفاده می‌کند: Avatar برای تصویر پروفایل، SocialPlatformBadge برای پلتفرم، Badge برای وضعیت، EngagementRateBar برای نرخ تعامل، و DropdownMenu برای اقدامات.

type SortDirection = 'asc' | 'desc' | null
type SortColumn = 'name' | 'followers' | 'engagementRate' | null

interface Influencer {
  id: number
  name: string
  avatar: string
  platform: 'instagram' | 'twitter' | 'tiktok' | 'youtube'
  followers: number
  engagementRate: number
  status: 'active' | 'pending' | 'inactive'
}

const influencers: Influencer[] = [
  {
    id: 1,
    name: 'سارا احمدی',
    avatar: '/avatars/sara.jpg',
    platform: 'instagram',
    followers: 125000,
    engagementRate: 0.042,
    status: 'active',
  },
  {
    id: 2,
    name: 'محمد رضایی',
    avatar: '/avatars/mohammad.jpg',
    platform: 'twitter',
    followers: 89000,
    engagementRate: 0.068,
    status: 'active',
  },
  {
    id: 3,
    name: 'نازنین کریمی',
    avatar: '/avatars/nazanin.jpg',
    platform: 'tiktok',
    followers: 350000,
    engagementRate: 0.021,
    status: 'pending',
  },
  {
    id: 4,
    name: 'علی محمدی',
    avatar: '/avatars/ali.jpg',
    platform: 'youtube',
    followers: 1200000,
    engagementRate: 0.015,
    status: 'inactive',
  },
]

const STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' }> = {
  active: { label: 'فعال', variant: 'default' },
  pending: { label: 'در انتظار', variant: 'secondary' },
  inactive: { label: 'غیرفعال', variant: 'destructive' },
}

function InfluencerTable() {
  const [sortColumn, setSortColumn] = useState<SortColumn>(null)
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)

  function handleSort(column: SortColumn) {
    if (sortColumn === column) {
      setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
    } else {
      setSortColumn(column)
      setSortDirection('asc')
    }
  }

  const sorted = useMemo(() => {
    return [...influencers].sort((a, b) => {
      if (!sortColumn || !sortDirection) return 0
      const dir = sortDirection === 'asc' ? 1 : -1
      if (sortColumn === 'name') return a.name.localeCompare(b.name, 'fa') * dir
      return (a[sortColumn] - b[sortColumn]) * dir
    })
  }, [sortColumn, sortDirection])

  const getSortDir = (col: SortColumn) => (sortColumn === col ? (sortDirection ?? 'none') : 'none')

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead sortDirection={getSortDir('name')}>
            <TableSortHeader sorted={sortColumn === 'name' ? sortDirection : false} onClick={() => handleSort('name')}>
              نام
            </TableSortHeader>
          </TableHead>
          <TableHead>پلتفرم</TableHead>
          <TableHead sortDirection={getSortDir('followers')}>
            <TableSortHeader
              sorted={sortColumn === 'followers' ? sortDirection : false}
              onClick={() => handleSort('followers')}
            >
              فالوورها
            </TableSortHeader>
          </TableHead>
          <TableHead sortDirection={getSortDir('engagementRate')}>
            <TableSortHeader
              sorted={sortColumn === 'engagementRate' ? sortDirection : false}
              onClick={() => handleSort('engagementRate')}
            >
              نرخ تعامل
            </TableSortHeader>
          </TableHead>
          <TableHead>وضعیت</TableHead>
          <TableHead className="w-[50px]" />
        </TableRow>
      </TableHeader>
      <TableBody>
        {sorted.map((influencer) => (
          <TableRow key={influencer.id}>
            <TableCell>
              <div className="flex items-center gap-3">
                <Avatar size="sm">
                  <AvatarImage src={influencer.avatar} alt={influencer.name} />
                  <AvatarFallback>{influencer.name[0]}</AvatarFallback>
                </Avatar>
                <span className="font-medium">{influencer.name}</span>
              </div>
            </TableCell>
            <TableCell>
              <SocialPlatformBadge platform={influencer.platform} size="sm" showLabel />
            </TableCell>
            <TableCell>{influencer.followers.toLocaleString('fa-IR')}</TableCell>
            <TableCell>
              <EngagementRateBar currentRate={influencer.engagementRate} followers={influencer.followers} locale="fa" />
            </TableCell>
            <TableCell>
              <Badge variant={STATUS_MAP[influencer.status].variant}>{STATUS_MAP[influencer.status].label}</Badge>
            </TableCell>
            <TableCell>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button variant="ghost" size="icon">
                    <MoreHorizontal className="h-4 w-4" />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem>مشاهده پروفایل</DropdownMenuItem>
                  <DropdownMenuItem>ویرایش</DropdownMenuItem>
                  <DropdownMenuItem variant="destructive">حذف</DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

حالت خالی

هنگامی که جدول نتیجه‌ای ندارد، حالت خالی را نمایش دهید. از کامپوننت‌های Empty، EmptyIcon، EmptyTitle، و EmptyDescription استفاده کنید.

function InfluencerEmptyState() {
  return (
    <Empty>
      <EmptyIcon>
        <Search className="h-6 w-6" />
      </EmptyIcon>
      <EmptyTitle>اینفلوئنسری یافت نشد</EmptyTitle>
      <EmptyDescription>فیلترهای خود را تغییر دهید یا اینفلوئنسر جدید اضافه کنید.</EmptyDescription>
      <Button className="mt-4" size="sm">
        <Plus className="h-4 w-4" />
        افزودن اینفلوئنسر
      </Button>
    </Empty>
  )
}

ترکیب با جدول به صورت شرطی:

{
  data.length === 0 ? <InfluencerEmptyState /> : <InfluencerTable data={data} />
}

حالت بارگذاری

برای حالت بارگذاری، از کامپوننت Skeleton در سلول‌های جدول استفاده کنید. ساختار جدول را حفظ کنید تا انتقال بین حالت بارگذاری و داده روان باشد.

function InfluencerTableSkeleton() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>نام</TableHead>
          <TableHead>پلتفرم</TableHead>
          <TableHead>فالوورها</TableHead>
          <TableHead>نرخ تعامل</TableHead>
          <TableHead>وضعیت</TableHead>
          <TableHead className="w-[50px]" />
        </TableRow>
      </TableHeader>
      <TableBody>
        {Array.from({ length: 5 }).map((_, i) => (
          <TableRow key={i}>
            <TableCell>
              <div className="flex items-center gap-3">
                <Skeleton className="h-8 w-8 rounded-full" />
                <Skeleton className="h-4 w-24" />
              </div>
            </TableCell>
            <TableCell>
              <Skeleton className="h-5 w-20" />
            </TableCell>
            <TableCell>
              <Skeleton className="h-4 w-16" />
            </TableCell>
            <TableCell>
              <Skeleton className="h-4 w-32" />
            </TableCell>
            <TableCell>
              <Skeleton className="h-5 w-14 rounded-full" />
            </TableCell>
            <TableCell>
              <Skeleton className="h-8 w-8" />
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

ترکیب سه حالت در صفحه:

{
  isLoading ? (
    <InfluencerTableSkeleton />
  ) : data.length === 0 ? (
    <InfluencerEmptyState />
  ) : (
    <InfluencerTable data={data} />
  )
}

صفحه‌بندی

از PaginationControlled برای صفحه‌بندی سمت سرور استفاده کنید. همیشه تعداد نتایج را نیز نمایش دهید.

function InfluencerPagination({
  currentPage,
  totalPages,
  totalCount,
  onPageChange,
}: {
  currentPage: number
  totalPages: number
  totalCount: number
  onPageChange: (page: number) => void
}) {
  return (
    <div className="flex items-center justify-between pt-4">
      <span className="text-sm text-muted-foreground">{totalCount.toLocaleString('fa-IR')} اینفلوئنسر</span>
      <PaginationControlled
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={onPageChange}
        showPrevNext
      />
    </div>
  )
}

الگوی مدیریت وضعیت

برای مدیریت وضعیت جستجو، فیلتر، مرتب‌سازی، و صفحه‌بندی به صورت یکپارچه، از یک useState واحد استفاده کنید. این روش ساده‌ترین و خواناترین رویکرد است.

'use client'

import { useState, useEffect, useCallback } from 'react'

interface TableState {
  search: string
  filters: { id: string; label: string; variant: 'default' | 'brand' }[]
  sortColumn: string | null
  sortDirection: 'asc' | 'desc' | null
  page: number
  pageSize: number
}

const initialState: TableState = {
  search: '',
  filters: [],
  sortColumn: null,
  sortDirection: null,
  page: 1,
  pageSize: 10,
}

export function useTableState() {
  const [state, setState] = useState<TableState>(initialState)

  // بازگشت به صفحه اول هنگام تغییر جستجو یا فیلتر
  const setSearch = useCallback((search: string) => {
    setState((prev) => ({ ...prev, search, page: 1 }))
  }, [])

  const addFilter = useCallback((filter: TableState['filters'][number]) => {
    setState((prev) => ({
      ...prev,
      filters: [...prev.filters, filter],
      page: 1,
    }))
  }, [])

  const removeFilter = useCallback((id: string) => {
    setState((prev) => ({
      ...prev,
      filters: prev.filters.filter((f) => f.id !== id),
      page: 1,
    }))
  }, [])

  const setSort = useCallback((column: string) => {
    setState((prev) => {
      if (prev.sortColumn === column) {
        return {
          ...prev,
          sortDirection: prev.sortDirection === 'asc' ? 'desc' : 'asc',
        }
      }
      return { ...prev, sortColumn: column, sortDirection: 'asc' }
    })
  }, [])

  const setPage = useCallback((page: number) => {
    setState((prev) => ({ ...prev, page }))
  }, [])

  return { state, setSearch, addFilter, removeFilter, setSort, setPage }
}

نکته مهم: هنگام تغییر جستجو یا فیلتر، همیشه صفحه را به 1 بازنشانی کنید. در غیر این صورت کاربر ممکن است در صفحه‌ای باشد که دیگر نتیجه‌ای ندارد.


بهترین شیوه‌ها

نمایش تعداد نتایج

همیشه تعداد نتایج را نمایش دهید. اگر نتیجه‌ای وجود ندارد، متن مناسب نشان دهید:

<span className="text-sm text-muted-foreground">
  {totalCount > 0 ? `${totalCount.toLocaleString('fa-IR')} اینفلوئنسر` : 'نتیجه‌ای یافت نشد'}
</span>

جستجو با تاخیر

از SearchInput با تاخیر (debounce) استفاده کنید تا با هر کلید فشرده‌شده درخواست به سرور ارسال نشود:

const [searchValue, setSearchValue] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')

useEffect(() => {
  const timer = setTimeout(() => {
    setDebouncedSearch(searchValue)
  }, 300)
  return () => clearTimeout(timer)
}, [searchValue])

دسترسی‌پذیری مرتب‌سازی

از TableSortHeader با aria-sort استفاده کنید. TableHead مقدار sortDirection را به صورت خودکار به aria-sort تبدیل می‌کند:

<TableHead sortDirection={sortColumn === 'name' ? sortDirection : 'none'}>
  <TableSortHeader sorted={sortColumn === 'name' ? sortDirection : false} onClick={() => handleSort('name')}>
    نام
  </TableSortHeader>
</TableHead>

محدود کردن ستون‌ها

تعداد ستون‌های جدول را بین ۵ تا ۷ ستون نگه دارید. اگر اطلاعات بیشتری لازم است، از صفحه جزئیات یا DropdownMenu برای اقدامات استفاده کنید.

فیلترهای فعال

فیلترهای فعال را همیشه به صورت FilterChip نمایش دهید تا کاربر بتواند آن‌ها را ببیند و حذف کند:

<FilterChipGroup>
  {activeFilters.map((filter) => (
    <FilterChip
      key={filter.id}
      label={filter.label}
      variant={filter.variant}
      onRemove={() => removeFilter(filter.id)}
    />
  ))}
</FilterChipGroup>

اقدام اصلی در هدر صفحه

دکمه اقدام اصلی (مانند «افزودن اینفلوئنسر») را در PageHeaderAside قرار دهید تا همیشه در دسترس باشد و با اسکرول صفحه حرکت نکند.

صفحه‌بندی سمت سرور

از PaginationControlled برای صفحه‌بندی سمت سرور استفاده کنید. این کامپوننت وضعیت خود را مدیریت نمی‌کند و صفحه فعلی را از طریق currentPage دریافت می‌کند:

<PaginationControlled
  currentPage={page}
  totalPages={Math.ceil(totalCount / pageSize)}
  onPageChange={setPage}
  showPrevNext
/>

مدیریت همه حالت‌ها

همیشه سه حالت را مدیریت کنید: بارگذاری، خالی، و دارای داده. هیچ‌گاه صفحه‌ای را بدون مدیریت حالت خالی و بارگذاری ارسال نکنید.