admin/components/paginated-list/PaginatedListTable.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

215 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import type { ReactNode } from 'react'
import { Table } from '@heroui/react'
import type { PaginationListColumnType } from '@/types'
import type { Selection, SortDescriptor } from '@/types/heroui'
import PaginatedListColumnFilter from '@/components/paginated-list/PaginatedListColumnFilter'
import PaginatedListCards from '@/components/paginated-list/PaginatedListCards'
import { getActiveSortColumn, type PaginatedListFilters, type PaginatedListRow } from '@/components/paginated-list/paginatedListUtils'
import Button from '@/components/formElements/Button'
import ArrowSwapIcon from '@/components/icons/ArrowSwapIcon'
import { TableSkeleton } from '@/components/feedback/LoadingState'
import AdminState from '@/components/feedback/AdminState'
interface Props {
bottomContent: ReactNode
columns: PaginationListColumnType[]
data: PaginatedListRow[]
filters: PaginatedListFilters
isLoading: boolean
loadError: string | null
selectedKeys: Selection
selectionMode: 'single' | 'multiple' | 'none'
sortDescriptor: SortDescriptor
sortString: string
tableWrapperClass: string
topContent: ReactNode
onRemoveFilter: (field: string, nestedField?: 'from' | 'to') => void
onRetry: () => void
onRenderCell: (item: PaginatedListRow, columnKey: React.Key) => ReactNode
onRunFilters: () => void
onSelectedKeysChange: (keys: Selection) => void
onSetFilter: (field: string, value: string | number, nestedField?: 'from' | 'to') => void
onSetSelectFilter: (field: string, value: string) => void
onSortChange: React.Dispatch<React.SetStateAction<SortDescriptor>>
}
export default function PaginatedListTable({
bottomContent,
columns,
data,
filters,
isLoading,
loadError,
selectedKeys,
selectionMode,
sortDescriptor,
sortString,
tableWrapperClass,
topContent,
onRemoveFilter,
onRetry,
onRenderCell,
onRunFilters,
onSelectedKeysChange,
onSetFilter,
onSetSelectFilter,
onSortChange,
}: Props) {
const isInitialLoading = isLoading && data.length === 0
const isRefreshing = isLoading && data.length > 0
// In RTL, the last column sits on the visual left — keep actions there.
const orderedColumns = [
...columns.filter((column) => column.field !== 'actions'),
...columns.filter((column) => column.field === 'actions'),
]
const rowHeaderField = orderedColumns.find((column) => column.field !== 'actions')?.field ?? orderedColumns[0]?.field
return (
<div className="admin-surface relative flex flex-col gap-0 overflow-hidden">
{isRefreshing ? (
<div
aria-live="polite"
className="absolute inset-x-0 top-0 z-30 h-1 overflow-hidden bg-primary-100"
role="status"
>
<span className="block h-full w-1/3 animate-[loading-slide_1s_ease-in-out_infinite] bg-primary motion-reduce:w-full motion-reduce:animate-none" />
<span className="sr-only">در حال بهروزرسانی جدول</span>
</div>
) : null}
{topContent}
{loadError ? (
<AdminState
actionLabel="تلاش دوباره"
className="m-4 min-h-36 rounded-2xl border border-fourth-100 bg-fourth-100/40"
description={loadError}
title="دریافت اطلاعات ناموفق بود"
variant="error"
onAction={onRetry}
/>
) : null}
{selectionMode === 'none' ? (
<PaginatedListCards
columns={orderedColumns}
data={data}
isLoading={isInitialLoading}
onRenderCell={onRenderCell}
/>
) : null}
<div className={selectionMode === 'none' ? 'hidden md:block' : ''}>
<Table className="gap-0">
<Table.ScrollContainer
className={`table-wrapper min-h-[260px] overflow-auto rounded-none bg-white p-0 shadow-none ${tableWrapperClass}`}
>
<Table.Content
aria-label="فهرست صفحه‌بندی‌شده"
className="min-w-full text-right"
selectedKeys={selectedKeys}
selectionMode={selectionMode === 'none' ? undefined : selectionMode}
sortDescriptor={sortDescriptor}
onSelectionChange={(keys) => {
onSelectedKeysChange(keys === 'all' ? 'all' : new Set(Array.from(keys, String)))
}}
>
<Table.Header columns={orderedColumns}>
{(column) => {
const isActions = column.field === 'actions'
return (
<Table.Column
key={column.field}
className={`border-b border-secondary-40 bg-secondary-50/90 text-smt-bold text-secondary-30 ${isActions ? 'text-left' : 'text-right'}`}
isRowHeader={column.field === rowHeaderField}
maxWidth={column.maxWidth}
minWidth={column.minWidth}
width={column.width as never}
onClick={(event) => {
const target = event.target as HTMLElement
if (target.tagName === 'INPUT') target.focus()
}}
>
<div className={`flex flex-col gap-2 py-1 ${isActions ? 'text-left' : 'text-right'}`}>
<div className={`flex h-8 items-center gap-1 ${isActions ? 'justify-start' : ''}`}>
<p>{column.label}</p>
{column.sortable && (
<Button
iconOnly
aria-label={`مرتب‌سازی بر اساس ${column.label}`}
color={getActiveSortColumn(sortString) === column.field ? 'primary' : 'default'}
size="sm"
variant="light"
onClick={() => {
onSortChange((current) => ({
column: column.field,
direction: current.direction === 'ascending' ? 'descending' : 'ascending',
}))
}}
>
<ArrowSwapIcon
className={getActiveSortColumn(sortString) === column.field ? 'size-4 text-primary' : 'size-4 text-text'}
/>
</Button>
)}
</div>
<PaginatedListColumnFilter
column={column}
value={filters[column.field]}
onRemove={onRemoveFilter}
onRun={onRunFilters}
onSelect={onSetSelectFilter}
onSet={onSetFilter}
/>
</div>
</Table.Column>
)
}}
</Table.Header>
<Table.Body
items={isInitialLoading ? [] : data}
renderEmptyState={() =>
isInitialLoading ? (
<div className="w-full bg-white">
<TableSkeleton
columnCount={orderedColumns.length || 4}
showHeader={false}
/>
</div>
) : (
<AdminState title="اطلاعاتی برای نمایش وجود ندارد" />
)
}
>
{(item) => (
<Table.Row
key={item.id}
className="transition-colors hover:bg-primary-50/40 data-[selected=true]:bg-primary-50"
columns={orderedColumns}
id={String(item.id)}
>
{(column) => {
const key = column.field
return (
<Table.Cell
className={`border-b border-secondary-40 py-3 text-xs text-secondary-30 ${key === 'actions' ? 'text-left' : 'text-right'}`}
>
{onRenderCell(item, key)}
</Table.Cell>
)
}}
</Table.Row>
)}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
{bottomContent}
</div>
)
}