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