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

Что добавлено:
- Слева дерево товарных групп (рекурсивное, с раскрытием), клик
  переключает фильтр 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:
nurdotnet 2026-04-23 23:18:43 +05:00 committed by nns
parent beae0ad604
commit 69e6fd808a
3 changed files with 311 additions and 51 deletions

View file

@ -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))
{

View 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>
)
}

View file

@ -1,64 +1,194 @@
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'
export function ProductsPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
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 (
<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} />
)}
>
<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."
/>
</ListPageShell>
<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 [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 (
<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}
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>
)
}