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] bool? isService,
|
||||
[FromQuery] bool? isWeighed,
|
||||
[FromQuery] bool? isMarked,
|
||||
[FromQuery] bool? isActive,
|
||||
[FromQuery] bool? hasBarcode,
|
||||
CancellationToken ct)
|
||||
{
|
||||
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 (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 (hasBarcode is not null)
|
||||
q = hasBarcode == true ? q.Where(p => p.Barcodes.Any()) : q.Where(p => !p.Barcodes.Any());
|
||||
|
||||
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,37 +1,159 @@
|
|||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Filter, X } from 'lucide-react'
|
||||
import { useCatalogList } from '@/lib/useCatalog'
|
||||
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
||||
import type { Product } from '@/lib/types'
|
||||
|
||||
const URL = '/api/catalog/products'
|
||||
|
||||
type TriFilter = 'all' | 'yes' | 'no'
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<div className="inline-flex rounded border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.v}
|
||||
type="button"
|
||||
onClick={() => onChange(o.v)}
|
||||
className={
|
||||
'px-2 py-0.5 ' +
|
||||
(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')
|
||||
}
|
||||
>
|
||||
{o.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
|
||||
const [filters, setFilters] = useState<Filters>(defaultFilters)
|
||||
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL, toExtra(filters))
|
||||
const activeCount = activeFilterCount(filters)
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title="Товары"
|
||||
description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
|
||||
<Link to="/catalog/products/new">
|
||||
<Button>
|
||||
<Plus className="w-4 h-4" /> Добавить
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
footer={data && data.total > 0 && (
|
||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* Left: groups tree */}
|
||||
<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">
|
||||
<ProductGroupTree
|
||||
selectedId={filters.groupId}
|
||||
onSelect={(id) => { setFilters({ ...filters, groupId: id }); setPage(1) }}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Right: products */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center gap-3 flex-wrap">
|
||||
<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}
|
||||
|
|
@ -59,6 +181,14 @@ export function ProductsPage() {
|
|||
]}
|
||||
empty="Товаров ещё нет. Они появятся после приёмки или через API."
|
||||
/>
|
||||
</ListPageShell>
|
||||
</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