Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
215 lines
8.5 KiB
TypeScript
215 lines
8.5 KiB
TypeScript
'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>
|
||
)
|
||
}
|