feat(catalog/products): tree-of-groups + фильтры как в MoySklad
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 29s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 36s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 29s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 36s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s
Что добавлено: - Слева дерево товарных групп (рекурсивное, с раскрытием), клик переключает фильтр ProductsPage. Клик на "Все товары" — показать весь каталог. Выбор группы включает её поддерево (матчинг по Path prefix на бэкенде, чтобы сабгруппы тоже попадали в выборку). - Кнопка "Фильтры" разворачивает верхнюю панель с тумблерами (all/да/нет): Активные, Услуга, Весовой, Маркируемый, Со штрихкодом. Счётчик в кнопке показывает количество активных не-дефолтных фильтров. - "Сбросить" очищает всё, кроме группы. API: - ProductsController.List: добавлены параметры `isMarked`, `hasBarcode`. `groupId` теперь фильтрует по Path-prefix (вся ветка вместо одной группы) — это ближе к UX MoySklad. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
beae0ad604
commit
69e6fd808a
|
|
@ -23,14 +23,33 @@ public class ProductsController : ControllerBase
|
||||||
[FromQuery] Guid? groupId,
|
[FromQuery] Guid? groupId,
|
||||||
[FromQuery] bool? isService,
|
[FromQuery] bool? isService,
|
||||||
[FromQuery] bool? isWeighed,
|
[FromQuery] bool? isWeighed,
|
||||||
|
[FromQuery] bool? isMarked,
|
||||||
[FromQuery] bool? isActive,
|
[FromQuery] bool? isActive,
|
||||||
|
[FromQuery] bool? hasBarcode,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var q = QueryIncludes().AsNoTracking();
|
var q = QueryIncludes().AsNoTracking();
|
||||||
if (groupId is not null) q = q.Where(p => p.ProductGroupId == groupId);
|
if (groupId is not null)
|
||||||
|
{
|
||||||
|
// Include the whole subtree: match on the group's Path prefix so sub-groups also show up.
|
||||||
|
var root = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(g => g.Id == groupId, ct);
|
||||||
|
if (root is not null)
|
||||||
|
{
|
||||||
|
var prefix = root.Path;
|
||||||
|
q = q.Where(p => p.ProductGroup != null &&
|
||||||
|
(p.ProductGroup.Path == prefix || p.ProductGroup.Path.StartsWith(prefix + "/")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
q = q.Where(p => p.ProductGroupId == groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isService is not null) q = q.Where(p => p.IsService == isService);
|
if (isService is not null) q = q.Where(p => p.IsService == isService);
|
||||||
if (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed);
|
if (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed);
|
||||||
|
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
|
||||||
if (isActive is not null) q = q.Where(p => p.IsActive == isActive);
|
if (isActive is not null) q = q.Where(p => p.IsActive == isActive);
|
||||||
|
if (hasBarcode is not null)
|
||||||
|
q = hasBarcode == true ? q.Where(p => p.Barcodes.Any()) : q.Where(p => !p.Barcodes.Any());
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
111
src/food-market.web/src/components/ProductGroupTree.tsx
Normal file
111
src/food-market.web/src/components/ProductGroupTree.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { ChevronRight, FolderTree } from 'lucide-react'
|
||||||
|
import { useProductGroups } from '@/lib/useLookups'
|
||||||
|
import type { ProductGroup } from '@/lib/types'
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
group: ProductGroup
|
||||||
|
children: TreeNode[]
|
||||||
|
productCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(groups: ProductGroup[]): TreeNode[] {
|
||||||
|
const byId = new Map<string, TreeNode>()
|
||||||
|
groups.forEach((g) => byId.set(g.id, { group: g, children: [] }))
|
||||||
|
const roots: TreeNode[] = []
|
||||||
|
byId.forEach((node) => {
|
||||||
|
if (node.group.parentId && byId.has(node.group.parentId)) {
|
||||||
|
byId.get(node.group.parentId)!.children.push(node)
|
||||||
|
} else {
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const sortRec = (nodes: TreeNode[]) => {
|
||||||
|
nodes.sort((a, b) => (a.group.sortOrder - b.group.sortOrder) || a.group.name.localeCompare(b.group.name, 'ru'))
|
||||||
|
nodes.forEach((n) => sortRec(n.children))
|
||||||
|
}
|
||||||
|
sortRec(roots)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductGroupTree({ selectedId, onSelect }: Props) {
|
||||||
|
const { data: groups, isLoading } = useProductGroups()
|
||||||
|
const tree = useMemo(() => buildTree(groups ?? []), [groups])
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderNode = (node: TreeNode, depth: number) => {
|
||||||
|
const hasChildren = node.children.length > 0
|
||||||
|
const isOpen = expanded.has(node.group.id)
|
||||||
|
const isActive = selectedId === node.group.id
|
||||||
|
return (
|
||||||
|
<div key={node.group.id}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex items-center gap-1 text-sm rounded cursor-pointer select-none pr-2 hover:bg-slate-100 dark:hover:bg-slate-800 ' +
|
||||||
|
(isActive ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 font-medium' : '')
|
||||||
|
}
|
||||||
|
style={{ paddingLeft: 4 + depth * 12 }}
|
||||||
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggle(node.group.id) }}
|
||||||
|
className="p-0.5 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||||
|
aria-label={isOpen ? 'Свернуть' : 'Развернуть'}
|
||||||
|
>
|
||||||
|
<ChevronRight className={'w-3.5 h-3.5 transition-transform ' + (isOpen ? 'rotate-90' : '')} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-[18px]" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(node.group.id)}
|
||||||
|
className="flex-1 text-left py-1 truncate"
|
||||||
|
title={node.group.path}
|
||||||
|
>
|
||||||
|
{node.group.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hasChildren && isOpen && node.children.map((c) => renderNode(c, depth + 1))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="px-2 py-2 border-b border-slate-200 dark:border-slate-800 flex items-center gap-2 text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
<FolderTree className="w-3.5 h-3.5" /> Группы
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto py-1">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex items-center gap-1 text-sm rounded cursor-pointer pr-2 hover:bg-slate-100 dark:hover:bg-slate-800 pl-2 ' +
|
||||||
|
(selectedId === null ? 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200 font-medium' : '')
|
||||||
|
}
|
||||||
|
onClick={() => onSelect(null)}
|
||||||
|
>
|
||||||
|
<button type="button" className="flex-1 text-left py-1">Все товары</button>
|
||||||
|
</div>
|
||||||
|
{isLoading && <div className="px-3 py-2 text-xs text-slate-400">Загрузка…</div>}
|
||||||
|
{!isLoading && tree.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs text-slate-400">Групп ещё нет</div>
|
||||||
|
)}
|
||||||
|
{tree.map((n) => renderNode(n, 0))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,64 +1,194 @@
|
||||||
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus, Filter, X } from 'lucide-react'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
||||||
import type { Product } from '@/lib/types'
|
import type { Product } from '@/lib/types'
|
||||||
|
|
||||||
const URL = '/api/catalog/products'
|
const URL = '/api/catalog/products'
|
||||||
|
|
||||||
export function ProductsPage() {
|
type TriFilter = 'all' | 'yes' | 'no'
|
||||||
const navigate = useNavigate()
|
|
||||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
|
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
groupId: string | null
|
||||||
|
isActive: TriFilter
|
||||||
|
isService: TriFilter
|
||||||
|
isWeighed: TriFilter
|
||||||
|
isMarked: TriFilter
|
||||||
|
hasBarcode: TriFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFilters: Filters = {
|
||||||
|
groupId: null,
|
||||||
|
isActive: 'yes',
|
||||||
|
isService: 'all',
|
||||||
|
isWeighed: 'all',
|
||||||
|
isMarked: 'all',
|
||||||
|
hasBarcode: 'all',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
|
||||||
|
const e: Record<string, string | number | boolean | undefined> = {}
|
||||||
|
if (f.groupId) e.groupId = f.groupId
|
||||||
|
if (f.isActive !== 'all') e.isActive = f.isActive === 'yes'
|
||||||
|
if (f.isService !== 'all') e.isService = f.isService === 'yes'
|
||||||
|
if (f.isWeighed !== 'all') e.isWeighed = f.isWeighed === 'yes'
|
||||||
|
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
|
||||||
|
if (f.hasBarcode !== 'all') e.hasBarcode = f.hasBarcode === 'yes'
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFilterCount = (f: Filters) => {
|
||||||
|
let n = 0
|
||||||
|
if (f.groupId) n++
|
||||||
|
if (f.isActive !== 'yes') n++ // 'yes' is default, count non-default
|
||||||
|
if (f.isService !== 'all') n++
|
||||||
|
if (f.isWeighed !== 'all') n++
|
||||||
|
if (f.isMarked !== 'all') n++
|
||||||
|
if (f.hasBarcode !== 'all') n++
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tri({
|
||||||
|
label, value, onChange, yesLabel = 'да', noLabel = 'нет',
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: TriFilter
|
||||||
|
onChange: (v: TriFilter) => void
|
||||||
|
yesLabel?: string
|
||||||
|
noLabel?: string
|
||||||
|
}) {
|
||||||
|
const opts: { v: TriFilter; t: string }[] = [
|
||||||
|
{ v: 'all', t: 'все' },
|
||||||
|
{ v: 'yes', t: yesLabel },
|
||||||
|
{ v: 'no', t: noLabel },
|
||||||
|
]
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<div className="flex items-center gap-2 text-xs">
|
||||||
title="Товары"
|
<span className="text-slate-500">{label}</span>
|
||||||
description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
|
<div className="inline-flex rounded border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||||
actions={
|
{opts.map((o) => (
|
||||||
<>
|
<button
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
|
key={o.v}
|
||||||
<Link to="/catalog/products/new">
|
type="button"
|
||||||
<Button>
|
onClick={() => onChange(o.v)}
|
||||||
<Plus className="w-4 h-4" /> Добавить
|
className={
|
||||||
</Button>
|
'px-2 py-0.5 ' +
|
||||||
</Link>
|
(value === o.v
|
||||||
</>
|
? 'bg-slate-900 text-white dark:bg-slate-200 dark:text-slate-900'
|
||||||
}
|
: 'bg-white dark:bg-slate-900 text-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800')
|
||||||
footer={data && data.total > 0 && (
|
}
|
||||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
>
|
||||||
)}
|
{o.t}
|
||||||
>
|
</button>
|
||||||
<DataTable
|
))}
|
||||||
rows={data?.items ?? []}
|
</div>
|
||||||
isLoading={isLoading}
|
</div>
|
||||||
rowKey={(r) => r.id}
|
)
|
||||||
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)}
|
}
|
||||||
columns={[
|
|
||||||
{ header: 'Название', cell: (r) => (
|
export function ProductsPage() {
|
||||||
<div>
|
const navigate = useNavigate()
|
||||||
<div className="font-medium">{r.name}</div>
|
const [filters, setFilters] = useState<Filters>(defaultFilters)
|
||||||
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||||
</div>
|
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL, toExtra(filters))
|
||||||
)},
|
const activeCount = activeFilterCount(filters)
|
||||||
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
|
|
||||||
{ header: 'Ед.', width: '70px', cell: (r) => r.unitName },
|
return (
|
||||||
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vat}%` },
|
<div className="flex h-full min-h-0">
|
||||||
{ header: 'Тип', width: '140px', cell: (r) => (
|
{/* Left: groups tree */}
|
||||||
<div className="flex gap-1 flex-wrap">
|
<aside className="w-64 flex-shrink-0 border-r border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-900/40 flex flex-col min-h-0">
|
||||||
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
|
<ProductGroupTree
|
||||||
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
|
selectedId={filters.groupId}
|
||||||
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
|
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
|
||||||
</div>
|
/>
|
||||||
)},
|
</aside>
|
||||||
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
|
|
||||||
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
{/* Right: products */}
|
||||||
]}
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
empty="Товаров ещё нет. Они появятся после приёмки или через API."
|
{/* Top bar */}
|
||||||
/>
|
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
|
||||||
</ListPageShell>
|
<div>
|
||||||
|
<h1 className="text-base font-semibold">Товары</h1>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<SearchBar value={search} onChange={setSearch} placeholder="Поиск: название, артикул, штрихкод…" />
|
||||||
|
<Button
|
||||||
|
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" /> Фильтры{activeCount > 0 ? ` (${activeCount})` : ''}
|
||||||
|
</Button>
|
||||||
|
<Link to="/catalog/products/new">
|
||||||
|
<Button><Plus className="w-4 h-4" /> Добавить</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter panel */}
|
||||||
|
{filtersOpen && (
|
||||||
|
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-4 items-center">
|
||||||
|
<Tri label="Активные" value={filters.isActive} onChange={(v) => { setFilters({ ...filters, isActive: v }); setPage(1) }} />
|
||||||
|
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
|
||||||
|
<Tri label="Весовой" value={filters.isWeighed} onChange={(v) => { setFilters({ ...filters, isWeighed: v }); setPage(1) }} />
|
||||||
|
<Tri label="Маркируемый" value={filters.isMarked} onChange={(v) => { setFilters({ ...filters, isMarked: v }); setPage(1) }} />
|
||||||
|
<Tri label="Со штрихкодом" value={filters.hasBarcode} onChange={(v) => { setFilters({ ...filters, hasBarcode: v }); setPage(1) }} yesLabel="есть" noLabel="нет" />
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setFilters(defaultFilters); setPage(1) }}
|
||||||
|
className="text-xs text-slate-500 hover:text-slate-800 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" /> Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
<DataTable
|
||||||
|
rows={data?.items ?? []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)}
|
||||||
|
columns={[
|
||||||
|
{ header: 'Название', cell: (r) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{r.name}</div>
|
||||||
|
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
{ header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
|
||||||
|
{ header: 'Ед.', width: '70px', cell: (r) => r.unitName },
|
||||||
|
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vat}%` },
|
||||||
|
{ header: 'Тип', width: '140px', cell: (r) => (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
|
||||||
|
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
|
||||||
|
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
|
||||||
|
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
|
||||||
|
]}
|
||||||
|
empty="Товаров ещё нет. Они появятся после приёмки или через API."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && data.total > 0 && (
|
||||||
|
<div className="px-6 py-3 border-t border-slate-200 dark:border-slate-800">
|
||||||
|
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue