صفحه جدول داده
الگوی ساخت صفحه لیست با جستجو، فیلتر، مرتبسازی، و صفحهبندی
معرفی
صفحه جدول داده رایجترین نوع صفحه در اپلیکیشنهای 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
/>مدیریت همه حالتها
همیشه سه حالت را مدیریت کنید: بارگذاری، خالی، و دارای داده. هیچگاه صفحهای را بدون مدیریت حالت خالی و بارگذاری ارسال نکنید.