From 69e6fd808afa77db5e6dca0831d627efc7f2f3ea Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:18:43 +0500 Subject: [PATCH] =?UTF-8?q?feat(catalog/products):=20tree-of-groups=20+=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D0=B2=20MoySklad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что добавлено: - Слева дерево товарных групп (рекурсивное, с раскрытием), клик переключает фильтр ProductsPage. Клик на "Все товары" — показать весь каталог. Выбор группы включает её поддерево (матчинг по Path prefix на бэкенде, чтобы сабгруппы тоже попадали в выборку). - Кнопка "Фильтры" разворачивает верхнюю панель с тумблерами (all/да/нет): Активные, Услуга, Весовой, Маркируемый, Со штрихкодом. Счётчик в кнопке показывает количество активных не-дефолтных фильтров. - "Сбросить" очищает всё, кроме группы. API: - ProductsController.List: добавлены параметры `isMarked`, `hasBarcode`. `groupId` теперь фильтрует по Path-prefix (вся ветка вместо одной группы) — это ближе к UX MoySklad. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controllers/Catalog/ProductsController.cs | 21 +- .../src/components/ProductGroupTree.tsx | 111 +++++++++ .../src/pages/ProductsPage.tsx | 230 ++++++++++++++---- 3 files changed, 311 insertions(+), 51 deletions(-) create mode 100644 src/food-market.web/src/components/ProductGroupTree.tsx diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 0972da3..92569a4 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -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)) { diff --git a/src/food-market.web/src/components/ProductGroupTree.tsx b/src/food-market.web/src/components/ProductGroupTree.tsx new file mode 100644 index 0000000..188b35a --- /dev/null +++ b/src/food-market.web/src/components/ProductGroupTree.tsx @@ -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() + 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>(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 ( +
+
+ {hasChildren ? ( + + ) : ( + + )} + +
+ {hasChildren && isOpen && node.children.map((c) => renderNode(c, depth + 1))} +
+ ) + } + + return ( +
+
+ Группы +
+
+
onSelect(null)} + > + +
+ {isLoading &&
Загрузка…
} + {!isLoading && tree.length === 0 && ( +
Групп ещё нет
+ )} + {tree.map((n) => renderNode(n, 0))} +
+
+ ) +} diff --git a/src/food-market.web/src/pages/ProductsPage.tsx b/src/food-market.web/src/pages/ProductsPage.tsx index 0d1cad1..b9aea62 100644 --- a/src/food-market.web/src/pages/ProductsPage.tsx +++ b/src/food-market.web/src/pages/ProductsPage.tsx @@ -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(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 => { + const e: Record = {} + 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 ( - - - - - - - } - footer={data && data.total > 0 && ( - - )} - > - r.id} - onRowClick={(r) => navigate(`/catalog/products/${r.id}`)} - columns={[ - { header: 'Название', cell: (r) => ( -
-
{r.name}
- {r.article &&
{r.article}
} -
- )}, - { 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) => ( -
- {r.isService && Услуга} - {r.isWeighed && Весовой} - {r.isMarked && Маркир.} -
- )}, - { header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length }, - { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, - ]} - empty="Товаров ещё нет. Они появятся после приёмки или через API." - /> -
+
+ {label} +
+ {opts.map((o) => ( + + ))} +
+
+ ) +} + +export function ProductsPage() { + const navigate = useNavigate() + const [filters, setFilters] = useState(defaultFilters) + const [filtersOpen, setFiltersOpen] = useState(false) + const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL, toExtra(filters)) + const activeCount = activeFilterCount(filters) + + return ( +
+ {/* Left: groups tree */} + + + {/* Right: products */} +
+ {/* Top bar */} +
+
+

Товары

+

+ {data ? `${data.total.toLocaleString('ru')} записей` : 'Каталог товаров и услуг'} +

+
+
+ + + + + +
+
+ + {/* Filter panel */} + {filtersOpen && ( +
+ { setFilters({ ...filters, isActive: v }); setPage(1) }} /> + { setFilters({ ...filters, isService: v }); setPage(1) }} /> + { setFilters({ ...filters, isWeighed: v }); setPage(1) }} /> + { setFilters({ ...filters, isMarked: v }); setPage(1) }} /> + { setFilters({ ...filters, hasBarcode: v }); setPage(1) }} yesLabel="есть" noLabel="нет" /> + {activeCount > 0 && ( + + )} +
+ )} + + {/* Table */} +
+ r.id} + onRowClick={(r) => navigate(`/catalog/products/${r.id}`)} + columns={[ + { header: 'Название', cell: (r) => ( +
+
{r.name}
+ {r.article &&
{r.article}
} +
+ )}, + { 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) => ( +
+ {r.isService && Услуга} + {r.isWeighed && Весовой} + {r.isMarked && Маркир.} +
+ )}, + { header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length }, + { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + ]} + empty="Товаров ещё нет. Они появятся после приёмки или через API." + /> +
+ + {data && data.total > 0 && ( +
+ +
+ )} +
+
) }