admin/components/paginated-list/PaginatedListImpl.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

471 lines
15 KiB
TypeScript

'use client'
import React, { forwardRef, Suspense, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import qs from 'qs'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import type { Selection, SortDescriptor } from '@/types/heroui'
import useDisclosure from '@/hooks/useDisclosure'
import { addToast } from '@/lib/toast'
import { TableSkeleton } from '@/components/feedback/LoadingState'
import { downloadExcel, isEmptyObject } from '@/helpers'
import { type ApiWithParams, type PaginationListColumnType } from '@/types'
import useQueryParams from '@/hooks/useQueryParams'
import { adminKeys } from '@/queries/admin/adminKeys'
import PaginatedListToolbar from '@/components/paginated-list/PaginatedListToolbar'
import PaginatedListPagination, { ROWS_PER_PAGE_OPTIONS } from '@/components/paginated-list/PaginatedListPagination'
import PaginatedListTable from '@/components/paginated-list/PaginatedListTable'
import { buildListParams, type PaginatedListRow } from '@/components/paginated-list/paginatedListUtils'
import { usePaginatedListFilters } from '@/components/paginated-list/usePaginatedListFilters'
import { fetchPaginatedList, fetchPaginatedListExport } from '@/components/paginated-list/paginatedListApi'
import { handleServiceError } from '@/services/errorHandler'
const PaginatedListFilterModal = dynamic(() => import('@/components/paginated-list/PaginatedListFilterModal'), {
ssr: false,
})
type ChildrenType = Record<string, (data: PaginatedListRow, cellValue: unknown) => React.ReactNode>
export interface PaginatedListHandle {
exelReport: (fileName: string, newUrl: string) => Promise<void>
filters: Record<string, unknown>
openFilterModal: () => void
refresh: () => void
}
interface PaginatedListProps {
bulkActions?: React.ReactNode | ((selectedKeys: Selection) => React.ReactNode)
columns: PaginationListColumnType[]
staticData?: PaginatedListRow[]
selectionMode?: 'single' | 'multiple' | 'none'
url?: string
itemsKey?: string
children?: ChildrenType | never[]
urlParams?: ApiWithParams
hasDynamicButton?: boolean
dynamicButtonText?: string
onDynamicButtonClick?: () => void
dynamicTopSection?: React.ReactNode
backBtnUrl?: string
filterPrefix?: string
tableWrapperClass?: string
}
const PaginatedList = forwardRef(
(
{
columns,
bulkActions,
staticData = [],
selectionMode = 'none',
url,
itemsKey,
children = {},
urlParams = {
page: 1,
pageSize: 20,
sort: '',
filters: {},
},
hasDynamicButton,
dynamicButtonText = 'افزودن',
onDynamicButtonClick,
dynamicTopSection,
backBtnUrl,
filterPrefix,
tableWrapperClass = 'table-max-height p-0',
}: PaginatedListProps,
ref
) => {
type DataType = PaginatedListRow
const { isOpen, onOpenChange, onOpen, onClose } = useDisclosure()
const router = useRouter()
const pathname = usePathname()
const queryParams = useQueryParams()
const queryClient = useQueryClient()
const [hasHydrated, setHasHydrated] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const {
clearFilters: handleClearFilter,
filters,
getFilters,
removeFilter: handleRemoveFilter,
setFilter: handleSetFilter,
setFilters,
setSelectFilter,
} = usePaginatedListFilters()
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]))
const [page, setPage] = useState(urlParams.page || 1)
const [rowsPerPage, setRowsPerPage] = useState(urlParams.pageSize || Number(ROWS_PER_PAGE_OPTIONS[0].code))
const [sortString, setSortString] = useState<string>('')
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: 'createdAt',
direction: 'ascending',
})
// استفاده از useRef برای تشخیص اولین اجرا
const isFirstRender = useRef(true)
const preparingFilter = (resultType?: number) =>
buildListParams({
filterPrefix,
// Read from ref so Enter/commit values are included before React re-renders.
filters: getFilters(),
page,
resultType,
rowsPerPage,
sort: sortString,
urlParams,
})
const fetchParams = preparingFilter()
const listQuery = useQuery({
queryKey: adminKeys.list(url ?? '', fetchParams),
queryFn: async ({ signal }) => {
try {
if (!url) throw new Error('url is required')
return await fetchPaginatedList({
itemsKey,
page,
params: fetchParams,
rowsPerPage,
signal,
url,
})
} catch (error: unknown) {
if (error instanceof Error && (error.name === 'CanceledError' || error.name === 'AbortError')) throw error
throw handleServiceError(error, { errorMode: 'silent' })
}
},
enabled: Boolean(url) && hasHydrated,
placeholderData: keepPreviousData,
})
const data: PaginatedListRow[] = url ? (listQuery.data?.items ?? []) : staticData
const totalItems = url ? (listQuery.data?.pagination.totalItems ?? 0) : staticData.length
const isLoading = (url ? listQuery.isLoading : false) || isExporting
const loadError = url && listQuery.error ? listQuery.error.message : null
// Exposing the function to parent or other components
useImperativeHandle(ref, () => ({
exelReport,
filters,
openFilterModal: onOpen,
refresh: () => {
if (url) void queryClient.invalidateQueries({ queryKey: adminKeys.listByUrl(url) })
},
}))
useEffect(() => {
if (!isEmptyObject(queryParams)) {
const { page: queryPage, pageSize: queryPageSize, sort } = queryParams
if (queryPage) {
setPage(Number(queryPage))
}
if (queryPageSize) {
setRowsPerPage(Number(queryPageSize))
}
if (sort) {
const direction = sort.startsWith('-') ? 'descending' : 'ascending'
const column = sort.startsWith('-') ? sort.slice(1) : sort
setSortDescriptor({
column,
direction,
})
}
const filtersArray = Object.keys(queryParams)?.filter((key) => key?.startsWith('filters'))
if (filtersArray?.length) {
filtersArray.map((item) => {
const value = queryParams[item]
const key = /\[(.*?)\]/.exec(item)?.[1]
if (key) {
if (value.includes(',')) {
setFilters((prev) => ({
...prev,
[key]: {
from: value.split(',')[0],
to: value.split(',')[1],
},
}))
} else {
setFilters((prev) => ({
...prev,
[key]: value,
}))
}
}
})
}
}
setHasHydrated(true)
// URL state is intentionally hydrated once, then the query below is
// allowed to run (see hasHydrated/enabled). Subscribing to queryParams
// here would form a loop with the URL-sync effect further down.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!listQuery.data) return
if (listQuery.data.pagination.page !== page) setPage(listQuery.data.pagination.page)
if (listQuery.data.pagination.pageSize !== rowsPerPage) setRowsPerPage(listQuery.data.pagination.pageSize)
// Mirrors the server's (possibly clamped/normalized) page/pageSize back
// into state, same as the old handleFetch's post-response sync.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listQuery.data])
useEffect(() => {
if (listQuery.error) {
addToast({ title: listQuery.error.message, color: 'danger' })
}
}, [listQuery.error])
useEffect(() => {
if (!hasHydrated || !url) return
const filteredQueryParams = Object.fromEntries(
Object.entries(queryParams).filter(([key]) => !(key in fetchParams) && !key.startsWith('filters'))
)
const urlWithFetchParams =
pathname?.split('?')[0] + '?' + qs.stringify({ ...filteredQueryParams, ...fetchParams }, { arrayFormat: 'brackets' })
router.replace(urlWithFetchParams, { scroll: false })
// These values are the explicit request triggers; queryParams/pathname/
// router are read fresh but shouldn't themselves re-trigger this sync.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasHydrated, url, page, rowsPerPage, sortString, filters])
useEffect(() => {
if (!listQuery.dataUpdatedAt) return undefined
const timer = setTimeout(() => {
const scrollableDiv = document.querySelector('.table-wrapper')
if (scrollableDiv) {
scrollableDiv.scrollTo({
top: 0,
behavior: 'smooth',
})
} else {
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
}, 250)
return () => {
clearTimeout(timer)
}
}, [listQuery.dataUpdatedAt])
const handleSort = () => {
const sortStringTemp = sortDescriptor.direction === 'ascending' ? sortDescriptor.column : `-${sortDescriptor.column}`
setSortString(sortStringTemp as string)
}
const pages = Math.ceil(totalItems / rowsPerPage)
useEffect(() => {
// چک کردن اینکه آیا اولین اجرا است یا نه
if (isFirstRender.current) {
isFirstRender.current = false
return // از اجرای درخواست در بار اول جلوگیری می‌کند
}
handleSort()
// sortDescriptor is the explicit sort trigger.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortDescriptor])
const renderCell = useCallback(
(item: DataType, columnKey: React.Key) => {
const cellValue = item[columnKey as keyof DataType]
// Ensure children is of type ChildrenType before using it
if (children && typeof children === 'object' && !(children instanceof Array) && (columnKey as string) in children) {
const columnKeyString = columnKey as keyof typeof children
return children[columnKeyString](item, cellValue)
}
return cellValue as React.ReactNode
},
[children]
)
const onNextPage = useCallback(() => {
if (page < pages) {
setPage(page + 1)
}
}, [page, pages])
const onPreviousPage = useCallback(() => {
if (page > 1) {
setPage(page - 1)
}
}, [page])
const onRowsPerPageChange = useCallback((nextRowsPerPage: number) => {
setRowsPerPage(nextRowsPerPage)
setPage(1)
}, [])
const runFilters = () => {
setPage(1)
onClose()
}
const exelReport = async (fileName: string, newUrl: string) => {
try {
setIsExporting(true)
const params = preparingFilter(1)
const exportUrl = newUrl || url
if (!exportUrl) return
const fileData = await fetchPaginatedListExport(exportUrl, params)
downloadExcel(fileData, fileName)
} finally {
setIsExporting(false)
}
}
const topContent = (
<PaginatedListToolbar
activeFilterCount={Object.keys(filters).length}
backBtnUrl={backBtnUrl}
bulkActions={typeof bulkActions === 'function' ? bulkActions(selectedKeys) : bulkActions}
dynamicButtonText={dynamicButtonText}
dynamicTopSection={dynamicTopSection}
hasDynamicButton={hasDynamicButton}
hasFilters={columns.some((column) => column.filterable)}
selectedKeys={selectedKeys}
selectionMode={selectionMode}
onBack={(href) => {
router.push(href)
}}
onClearSelection={() => {
setSelectedKeys(new Set())
}}
onDynamicButtonClick={onDynamicButtonClick}
onOpenFilters={onOpen}
/>
)
const bottomContent = (
<PaginatedListPagination
dataLength={data.length}
page={page}
pages={pages}
rowsPerPage={rowsPerPage}
selectedKeys={selectedKeys}
selectionMode={selectionMode}
totalItems={totalItems}
onNextPage={onNextPage}
onPageChange={setPage}
onPreviousPage={onPreviousPage}
onRowsPerPageChange={onRowsPerPageChange}
/>
)
return (
<>
<PaginatedListTable
bottomContent={bottomContent}
columns={columns.filter((column) => !column.hideInTable)}
data={data}
filters={filters}
isLoading={isLoading}
loadError={loadError}
selectedKeys={selectedKeys}
selectionMode={selectionMode}
sortDescriptor={sortDescriptor}
sortString={sortString}
tableWrapperClass={tableWrapperClass}
topContent={topContent}
onRemoveFilter={handleRemoveFilter}
onRenderCell={renderCell}
onRetry={() => void listQuery.refetch()}
onRunFilters={runFilters}
onSelectedKeysChange={setSelectedKeys}
onSetFilter={handleSetFilter}
onSetSelectFilter={setSelectFilter}
onSortChange={setSortDescriptor}
/>
{isOpen ? (
<PaginatedListFilterModal
columns={columns}
filters={filters}
isLoading={isLoading}
isOpen={isOpen}
onAccept={runFilters}
onClear={handleClearFilter}
onOpenChange={onOpenChange}
onRemove={handleRemoveFilter}
onSelect={setSelectFilter}
onSet={handleSetFilter}
/>
) : null}
</>
)
}
)
PaginatedList.displayName = 'PaginatedList'
const PaginatedListWrapper = forwardRef((props: PaginatedListProps, ref) => {
const columnCount = props.columns?.length || 4
return (
<Suspense
fallback={
<div className="admin-surface overflow-hidden">
{props.columns?.length ? (
<div className="flex gap-6 border-b border-secondary-40 bg-secondary-50/90 px-4 py-3">
{props.columns.map((column) => (
<p
key={column.field}
className="min-w-0 flex-1 text-right text-smt-bold text-secondary-30"
>
{column.label}
</p>
))}
</div>
) : null}
<TableSkeleton
columnCount={columnCount}
showHeader={false}
/>
</div>
}
>
<PaginatedList
ref={ref}
{...props}
/>
</Suspense>
)
})
PaginatedListWrapper.displayName = 'PaginatedListWrapper'
export default PaginatedListWrapper