From 5a06e15924f1c0423c67127e5cc6430284a6fb6b Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:24:05 +0500 Subject: [PATCH] feat(tables): server-side sort by column header click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Во всех таблицах можно сортировать по клику на заголовок столбца: первый клик — по возрастанию (↑), второй — по убыванию (↓), смена колонки сбрасывает предыдущую. Без активной сортировки — серверный default (обычно по Name ASC). Реализация: - PagedRequest: добавлены Sort (ключ колонки) и Order ("asc"/"desc"), плюс удобное свойство Desc. - DataTable: Column.sortKey + props sortKey/sortOrder/onSortChange, в заголовке появляется иконка (ArrowUpDown/ArrowUp/ArrowDown). - useCatalogList: хранит sortKey/sortOrder, отдаёт setSort, шлёт ?sort=&order= в query-string. - Все 10 List-эндпоинтов (Countries, Currencies, UnitsOfMeasure, PriceTypes, Stores, RetailPoints, Counterparties, ProductGroups, Products, Supplies, RetailSales + Stock/Movements) принимают параметры и применяют switch-based OrderBy по whitelisted ключам. - Все страницы со списками прокидывают sort state и sortKey на колонках, где сортировка имеет смысл (тексты/числа/даты). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Catalog/CounterpartiesController.cs | 16 +++++++- .../Catalog/CountriesController.cs | 12 +++++- .../Catalog/CurrenciesController.cs | 12 +++++- .../Catalog/PriceTypesController.cs | 13 +++++- .../Catalog/ProductGroupsController.cs | 11 ++++- .../Controllers/Catalog/ProductsController.cs | 16 +++++++- .../Catalog/RetailPointsController.cs | 14 ++++++- .../Controllers/Catalog/StoresController.cs | 15 ++++++- .../Catalog/UnitsOfMeasureController.cs | 10 ++++- .../Controllers/Inventory/StockController.cs | 41 ++++++++++++++++++- .../Purchases/SuppliesController.cs | 16 +++++++- .../Sales/RetailSalesController.cs | 14 ++++++- .../Common/PagedRequest.cs | 7 +++- .../src/components/DataTable.tsx | 38 ++++++++++++++++- src/food-market.web/src/lib/useCatalog.ts | 16 ++++++-- .../src/pages/CounterpartiesPage.tsx | 15 ++++--- .../src/pages/CountriesPage.tsx | 13 +++--- .../src/pages/CurrenciesPage.tsx | 13 +++--- .../src/pages/PriceTypesPage.tsx | 13 +++--- .../src/pages/ProductGroupsPage.tsx | 11 +++-- .../src/pages/ProductsPage.tsx | 16 +++++--- .../src/pages/RetailPointsPage.tsx | 15 ++++--- .../src/pages/RetailSalesPage.tsx | 15 ++++--- .../src/pages/StockMovementsPage.tsx | 22 ++++++---- src/food-market.web/src/pages/StockPage.tsx | 22 ++++++---- src/food-market.web/src/pages/StoresPage.tsx | 15 ++++--- .../src/pages/SuppliesPage.tsx | 17 ++++---- .../src/pages/UnitsOfMeasurePage.tsx | 11 +++-- 28 files changed, 356 insertions(+), 93 deletions(-) diff --git a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs index 217cf6f..af4e9a6 100644 --- a/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs +++ b/src/food-market.api/Controllers/Catalog/CounterpartiesController.cs @@ -34,8 +34,22 @@ public class CounterpartiesController : ControllerBase (c.Phone != null && c.Phone.Contains(s))); } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("type", false) => q.OrderBy(c => c.Type).ThenBy(c => c.Name), + ("type", true) => q.OrderByDescending(c => c.Type).ThenBy(c => c.Name), + ("country", false) => q.OrderBy(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name), + ("country", true) => q.OrderByDescending(c => c.Country != null ? c.Country.Name : null).ThenBy(c => c.Name), + ("legalName", false) => q.OrderBy(c => c.LegalName).ThenBy(c => c.Name), + ("legalName", true) => q.OrderByDescending(c => c.LegalName).ThenBy(c => c.Name), + ("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name), + ("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name), + ("isActive", false) => q.OrderBy(c => c.IsActive).ThenBy(c => c.Name), + ("isActive", true) => q.OrderByDescending(c => c.IsActive).ThenBy(c => c.Name), + ("name", true) => q.OrderByDescending(c => c.Name), + _ => q.OrderBy(c => c.Name), + }; var items = await q - .OrderBy(c => c.Name) .Skip(req.Skip).Take(req.Take) .Select(c => new CounterpartyDto( c.Id, c.Name, c.LegalName, c.Type, diff --git a/src/food-market.api/Controllers/Catalog/CountriesController.cs b/src/food-market.api/Controllers/Catalog/CountriesController.cs index a15ff74..0836f84 100644 --- a/src/food-market.api/Controllers/Catalog/CountriesController.cs +++ b/src/food-market.api/Controllers/Catalog/CountriesController.cs @@ -27,8 +27,18 @@ public async Task>> List([FromQuery] PagedR q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s)); } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("code", false) => q.OrderBy(c => c.Code), + ("code", true) => q.OrderByDescending(c => c.Code), + ("currency", false) => q.OrderBy(c => c.DefaultCurrency != null ? c.DefaultCurrency.Code : null).ThenBy(c => c.Name), + ("currency", true) => q.OrderByDescending(c => c.DefaultCurrency != null ? c.DefaultCurrency.Code : null).ThenBy(c => c.Name), + ("vatRate", false) => q.OrderBy(c => c.VatRate).ThenBy(c => c.Name), + ("vatRate", true) => q.OrderByDescending(c => c.VatRate).ThenBy(c => c.Name), + ("name", true) => q.OrderByDescending(c => c.Name), + _ => q.OrderBy(c => c.Name), + }; var items = await q - .OrderBy(c => c.Name) .Skip(req.Skip).Take(req.Take) .Select(c => new CountryDto( c.Id, c.Code, c.Name, diff --git a/src/food-market.api/Controllers/Catalog/CurrenciesController.cs b/src/food-market.api/Controllers/Catalog/CurrenciesController.cs index 93b37db..9a088b8 100644 --- a/src/food-market.api/Controllers/Catalog/CurrenciesController.cs +++ b/src/food-market.api/Controllers/Catalog/CurrenciesController.cs @@ -27,8 +27,18 @@ public async Task>> List([FromQuery] Paged q = q.Where(c => c.Name.ToLower().Contains(s) || c.Code.ToLower().Contains(s)); } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("name", false) => q.OrderBy(c => c.Name), + ("name", true) => q.OrderByDescending(c => c.Name), + ("symbol", false) => q.OrderBy(c => c.Symbol), + ("symbol", true) => q.OrderByDescending(c => c.Symbol), + ("isActive", false) => q.OrderBy(c => c.IsActive).ThenBy(c => c.Code), + ("isActive", true) => q.OrderByDescending(c => c.IsActive).ThenBy(c => c.Code), + ("code", true) => q.OrderByDescending(c => c.Code), + _ => q.OrderBy(c => c.Code), + }; var items = await q - .OrderBy(c => c.Code) .Skip(req.Skip).Take(req.Take) .Select(c => new CurrencyDto(c.Id, c.Code, c.Name, c.Symbol, c.MinorUnit, c.IsActive)) .ToListAsync(ct); diff --git a/src/food-market.api/Controllers/Catalog/PriceTypesController.cs b/src/food-market.api/Controllers/Catalog/PriceTypesController.cs index be67bba..931b041 100644 --- a/src/food-market.api/Controllers/Catalog/PriceTypesController.cs +++ b/src/food-market.api/Controllers/Catalog/PriceTypesController.cs @@ -27,8 +27,19 @@ public async Task>> List([FromQuery] Page q = q.Where(p => p.Name.ToLower().Contains(s)); } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("name", false) => q.OrderBy(p => p.Name), + ("name", true) => q.OrderByDescending(p => p.Name), + ("isDefault", false) => q.OrderBy(p => p.IsDefault).ThenBy(p => p.Name), + ("isDefault", true) => q.OrderByDescending(p => p.IsDefault).ThenBy(p => p.Name), + ("isRetail", false) => q.OrderBy(p => p.IsRetail).ThenBy(p => p.Name), + ("isRetail", true) => q.OrderByDescending(p => p.IsRetail).ThenBy(p => p.Name), + ("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name), + ("isActive", true) => q.OrderByDescending(p => p.IsActive).ThenBy(p => p.Name), + _ => q.OrderByDescending(p => p.IsDefault).ThenBy(p => p.SortOrder).ThenBy(p => p.Name), + }; var items = await q - .OrderByDescending(p => p.IsDefault).ThenBy(p => p.SortOrder).ThenBy(p => p.Name) .Skip(req.Skip).Take(req.Take) .Select(p => new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive)) .ToListAsync(ct); diff --git a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs index 6a0be88..f7fec07 100644 --- a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs @@ -34,8 +34,17 @@ public class ProductGroupsController : ControllerBase q = q.Where(g => g.Name.ToLower().Contains(s) || g.Path.ToLower().Contains(s)); } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("name", false) => q.OrderBy(g => g.Name), + ("name", true) => q.OrderByDescending(g => g.Name), + ("path", false) => q.OrderBy(g => g.Path), + ("path", true) => q.OrderByDescending(g => g.Path), + ("isActive", false) => q.OrderBy(g => g.IsActive).ThenBy(g => g.Path), + ("isActive", true) => q.OrderByDescending(g => g.IsActive).ThenBy(g => g.Path), + _ => q.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name), + }; var items = await q - .OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name) .Skip(req.Skip).Take(req.Take) .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive)) .ToListAsync(ct); diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index dd74e6c..71001eb 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -84,8 +84,22 @@ private async Task ResolveDefaultVatAsync(CancellationToken ct) } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("article", false) => q.OrderBy(p => p.Article).ThenBy(p => p.Name), + ("article", true) => q.OrderByDescending(p => p.Article).ThenBy(p => p.Name), + ("group", false) => q.OrderBy(p => p.ProductGroup != null ? p.ProductGroup.Name : null).ThenBy(p => p.Name), + ("group", true) => q.OrderByDescending(p => p.ProductGroup != null ? p.ProductGroup.Name : null).ThenBy(p => p.Name), + ("unit", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name), + ("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name), + ("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name), + ("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name), + ("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name), + ("isActive", true) => q.OrderByDescending(p => p.IsActive).ThenBy(p => p.Name), + ("name", true) => q.OrderByDescending(p => p.Name), + _ => q.OrderBy(p => p.Name), + }; var items = await q - .OrderBy(p => p.Name) .Skip(req.Skip).Take(req.Take) .Select(Projection) .ToListAsync(ct); diff --git a/src/food-market.api/Controllers/Catalog/RetailPointsController.cs b/src/food-market.api/Controllers/Catalog/RetailPointsController.cs index 8e0c822..edbeb16 100644 --- a/src/food-market.api/Controllers/Catalog/RetailPointsController.cs +++ b/src/food-market.api/Controllers/Catalog/RetailPointsController.cs @@ -27,8 +27,20 @@ public async Task>> List([FromQuery] Pa q = q.Where(r => r.Name.ToLower().Contains(s) || (r.Code != null && r.Code.ToLower().Contains(s))); } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("code", false) => q.OrderBy(r => r.Code).ThenBy(r => r.Name), + ("code", true) => q.OrderByDescending(r => r.Code).ThenBy(r => r.Name), + ("store", false) => q.OrderBy(r => r.Store!.Name).ThenBy(r => r.Name), + ("store", true) => q.OrderByDescending(r => r.Store!.Name).ThenBy(r => r.Name), + ("address", false) => q.OrderBy(r => r.Address).ThenBy(r => r.Name), + ("address", true) => q.OrderByDescending(r => r.Address).ThenBy(r => r.Name), + ("isActive", false) => q.OrderBy(r => r.IsActive).ThenBy(r => r.Name), + ("isActive", true) => q.OrderByDescending(r => r.IsActive).ThenBy(r => r.Name), + ("name", true) => q.OrderByDescending(r => r.Name), + _ => q.OrderBy(r => r.Name), + }; var items = await q - .OrderBy(r => r.Name) .Skip(req.Skip).Take(req.Take) .Select(r => new RetailPointDto(r.Id, r.Name, r.Code, r.StoreId, r.Store!.Name, r.Address, r.Phone, r.FiscalSerial, r.FiscalRegNumber, r.IsActive)) diff --git a/src/food-market.api/Controllers/Catalog/StoresController.cs b/src/food-market.api/Controllers/Catalog/StoresController.cs index 54bb980..a464bdb 100644 --- a/src/food-market.api/Controllers/Catalog/StoresController.cs +++ b/src/food-market.api/Controllers/Catalog/StoresController.cs @@ -27,8 +27,21 @@ public async Task>> List([FromQuery] PagedReq q = q.Where(x => x.Name.ToLower().Contains(s) || (x.Code != null && x.Code.ToLower().Contains(s))); } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("name", false) => q.OrderBy(x => x.Name), + ("name", true) => q.OrderByDescending(x => x.Name), + ("code", false) => q.OrderBy(x => x.Code).ThenBy(x => x.Name), + ("code", true) => q.OrderByDescending(x => x.Code).ThenBy(x => x.Name), + ("address", false) => q.OrderBy(x => x.Address).ThenBy(x => x.Name), + ("address", true) => q.OrderByDescending(x => x.Address).ThenBy(x => x.Name), + ("isMain", false) => q.OrderBy(x => x.IsMain).ThenBy(x => x.Name), + ("isMain", true) => q.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name), + ("isActive", false) => q.OrderBy(x => x.IsActive).ThenBy(x => x.Name), + ("isActive", true) => q.OrderByDescending(x => x.IsActive).ThenBy(x => x.Name), + _ => q.OrderByDescending(x => x.IsMain).ThenBy(x => x.Name), + }; var items = await q - .OrderByDescending(x => x.IsMain).ThenBy(x => x.Name) .Skip(req.Skip).Take(req.Take) .Select(x => new StoreDto(x.Id, x.Name, x.Code, x.Address, x.Phone, x.ManagerName, x.IsMain, x.IsActive)) .ToListAsync(ct); diff --git a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs index 58bac55..f108dc9 100644 --- a/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs +++ b/src/food-market.api/Controllers/Catalog/UnitsOfMeasureController.cs @@ -27,8 +27,16 @@ public async Task>> List([FromQuery] q = q.Where(u => u.Name.ToLower().Contains(s) || u.Code.ToLower().Contains(s)); } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("code", false) => q.OrderBy(u => u.Code), + ("code", true) => q.OrderByDescending(u => u.Code), + ("isActive", false) => q.OrderBy(u => u.IsActive).ThenBy(u => u.Name), + ("isActive", true) => q.OrderByDescending(u => u.IsActive).ThenBy(u => u.Name), + ("name", true) => q.OrderByDescending(u => u.Name), + _ => q.OrderBy(u => u.Name), + }; var items = await q - .OrderBy(u => u.Name) .Skip(req.Skip).Take(req.Take) .Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive)) .ToListAsync(ct); diff --git a/src/food-market.api/Controllers/Inventory/StockController.cs b/src/food-market.api/Controllers/Inventory/StockController.cs index d798bcf..a9282b9 100644 --- a/src/food-market.api/Controllers/Inventory/StockController.cs +++ b/src/food-market.api/Controllers/Inventory/StockController.cs @@ -27,6 +27,8 @@ public record StockRow( [FromQuery] bool includeZero = false, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, + [FromQuery] string? sort = null, + [FromQuery] string? order = null, CancellationToken ct = default) { var q = from s in _db.Stocks @@ -46,8 +48,25 @@ public record StockRow( } var total = await q.CountAsync(ct); + var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase); + q = (sort, desc) switch + { + ("article", false) => q.OrderBy(x => x.p.Article).ThenBy(x => x.p.Name), + ("article", true) => q.OrderByDescending(x => x.p.Article).ThenBy(x => x.p.Name), + ("unit", false) => q.OrderBy(x => x.u.Name).ThenBy(x => x.p.Name), + ("unit", true) => q.OrderByDescending(x => x.u.Name).ThenBy(x => x.p.Name), + ("store", false) => q.OrderBy(x => x.st.Name).ThenBy(x => x.p.Name), + ("store", true) => q.OrderByDescending(x => x.st.Name).ThenBy(x => x.p.Name), + ("quantity", false) => q.OrderBy(x => x.s.Quantity).ThenBy(x => x.p.Name), + ("quantity", true) => q.OrderByDescending(x => x.s.Quantity).ThenBy(x => x.p.Name), + ("reserved", false) => q.OrderBy(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name), + ("reserved", true) => q.OrderByDescending(x => x.s.ReservedQuantity).ThenBy(x => x.p.Name), + ("available", false) => q.OrderBy(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name), + ("available", true) => q.OrderByDescending(x => x.s.Quantity - x.s.ReservedQuantity).ThenBy(x => x.p.Name), + ("name", true) => q.OrderByDescending(x => x.p.Name), + _ => q.OrderBy(x => x.p.Name), + }; var items = await q - .OrderBy(x => x.p.Name) .Skip((page - 1) * pageSize).Take(pageSize) .Select(x => new StockRow( x.p.Id, x.p.Name, x.p.Article, x.u.Name, @@ -74,6 +93,8 @@ public record MovementRow( [FromQuery] DateTime? to, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, + [FromQuery] string? sort = null, + [FromQuery] string? order = null, CancellationToken ct = default) { var q = from m in _db.StockMovements @@ -87,8 +108,24 @@ public record MovementRow( if (to.HasValue) q = q.Where(x => x.m.OccurredAt < to.Value); var total = await q.CountAsync(ct); + var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase); + q = (sort, desc) switch + { + ("occurredAt", false) => q.OrderBy(x => x.m.OccurredAt), + ("occurredAt", true) => q.OrderByDescending(x => x.m.OccurredAt), + ("product", false) => q.OrderBy(x => x.p.Name), + ("product", true) => q.OrderByDescending(x => x.p.Name), + ("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.m.OccurredAt), + ("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.m.OccurredAt), + ("quantity", false) => q.OrderBy(x => x.m.Quantity).ThenByDescending(x => x.m.OccurredAt), + ("quantity", true) => q.OrderByDescending(x => x.m.Quantity).ThenByDescending(x => x.m.OccurredAt), + ("type", false) => q.OrderBy(x => x.m.Type).ThenByDescending(x => x.m.OccurredAt), + ("type", true) => q.OrderByDescending(x => x.m.Type).ThenByDescending(x => x.m.OccurredAt), + ("documentType", false) => q.OrderBy(x => x.m.DocumentType).ThenByDescending(x => x.m.OccurredAt), + ("documentType", true) => q.OrderByDescending(x => x.m.DocumentType).ThenByDescending(x => x.m.OccurredAt), + _ => q.OrderByDescending(x => x.m.OccurredAt), + }; var items = await q - .OrderByDescending(x => x.m.OccurredAt) .Skip((page - 1) * pageSize).Take(pageSize) .Select(x => new MovementRow( x.m.Id, x.m.OccurredAt, diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index 85ca766..d289a63 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -77,8 +77,22 @@ public record SupplyInput( } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("number", false) => q.OrderBy(x => x.s.Number), + ("number", true) => q.OrderByDescending(x => x.s.Number), + ("supplier", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.s.Date), + ("supplier", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.s.Date), + ("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date), + ("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date), + ("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date), + ("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date), + ("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date), + ("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date), + ("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number), + _ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number), + }; var items = await q - .OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number) .Skip(req.Skip).Take(req.Take) .Select(x => new SupplyListRow( x.s.Id, x.s.Number, x.s.Date, x.s.Status, diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index 3631ed1..a9a46e5 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -148,8 +148,20 @@ public record SalesStatsResponse( } var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("number", false) => q.OrderBy(x => x.s.Number), + ("number", true) => q.OrderByDescending(x => x.s.Number), + ("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date), + ("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date), + ("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date), + ("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date), + ("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date), + ("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date), + ("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number), + _ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number), + }; var items = await q - .OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number) .Skip(req.Skip).Take(req.Take) .Select(x => new RetailSaleListRow( x.s.Id, x.s.Number, x.s.Date, x.s.Status, diff --git a/src/food-market.application/Common/PagedRequest.cs b/src/food-market.application/Common/PagedRequest.cs index c3e7764..b3dbac2 100644 --- a/src/food-market.application/Common/PagedRequest.cs +++ b/src/food-market.application/Common/PagedRequest.cs @@ -5,11 +5,14 @@ public sealed class PagedRequest public int Page { get; init; } = 1; public int PageSize { get; init; } = 50; public string? Search { get; init; } - public string? SortBy { get; init; } - public bool SortDesc { get; init; } + /// Ключ колонки, по которой сортировать (см. switch в контроллере). + public string? Sort { get; init; } + /// "asc" (дефолт) или "desc". + public string? Order { get; init; } public int Skip => Math.Max(0, (Page - 1) * PageSize); public int Take => Math.Clamp(PageSize, 1, 500); + public bool Desc => string.Equals(Order, "desc", StringComparison.OrdinalIgnoreCase); } public sealed class PagedResult diff --git a/src/food-market.web/src/components/DataTable.tsx b/src/food-market.web/src/components/DataTable.tsx index a84ad1e..03522b8 100644 --- a/src/food-market.web/src/components/DataTable.tsx +++ b/src/food-market.web/src/components/DataTable.tsx @@ -1,11 +1,16 @@ import type { ReactNode } from 'react' +import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' import { cn } from '@/lib/utils' +export type SortOrder = 'asc' | 'desc' + export interface Column { header: string cell: (row: T) => ReactNode className?: string width?: string + /** Если задан — заголовок становится кликабельным, при клике вызывает onSortChange. */ + sortKey?: string } interface DataTableProps { @@ -18,11 +23,42 @@ interface DataTableProps { /** If true (default), the table wraps itself in a scrollable container with a sticky thead. * If false, use when the caller provides its own scroll container. */ scrollable?: boolean + /** Текущий ключ сортировки (null если сортировка не активна). */ + sortKey?: string | null + sortOrder?: SortOrder + /** Колбэк кликов по заголовку. Если не задан — заголовки не кликабельны. */ + onSortChange?: (key: string, order: SortOrder) => void } export function DataTable({ rows, columns, rowKey, onRowClick, empty, isLoading, scrollable = true, + sortKey, sortOrder, onSortChange, }: DataTableProps) { + const handleHeaderClick = (key: string) => { + if (!onSortChange) return + const nextOrder: SortOrder = sortKey === key && sortOrder === 'asc' ? 'desc' : 'asc' + onSortChange(key, nextOrder) + } + + const renderHeader = (c: Column) => { + if (!c.sortKey || !onSortChange) return c.header + const active = sortKey === c.sortKey + const Icon = active ? (sortOrder === 'desc' ? ArrowDown : ArrowUp) : ArrowUpDown + return ( + + ) + } + const table = ( @@ -36,7 +72,7 @@ export function DataTable({ )} style={c.width ? { width: c.width } : undefined} > - {c.header} + {renderHeader(c)} ))} diff --git a/src/food-market.web/src/lib/useCatalog.ts b/src/food-market.web/src/lib/useCatalog.ts index 28c71d9..d903fc6 100644 --- a/src/food-market.web/src/lib/useCatalog.ts +++ b/src/food-market.web/src/lib/useCatalog.ts @@ -1,19 +1,29 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api } from '@/lib/api' import type { PagedResult } from '@/lib/types' +import type { SortOrder } from '@/components/DataTable' export function useCatalogList(url: string, extraParams: Record = {}) { const [page, setPage] = useState(1) const [search, setSearch] = useState('') + const [sortKey, setSortKey] = useState(null) + const [sortOrder, setSortOrder] = useState('asc') + + const setSort = useCallback((key: string, order: SortOrder) => { + setSortKey(key) + setSortOrder(order) + setPage(1) + }, []) const query = useQuery({ - queryKey: [url, page, search, extraParams], + queryKey: [url, page, search, sortKey, sortOrder, extraParams], queryFn: async () => { const params = new URLSearchParams({ page: String(page), pageSize: '50', ...(search ? { search } : {}), + ...(sortKey ? { sort: sortKey, order: sortOrder } : {}), ...Object.fromEntries( Object.entries(extraParams).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => [k, String(v)]) ), @@ -24,7 +34,7 @@ export function useCatalogList(url: string, extraParams: Record prev, }) - return { page, setPage, search, setSearch, ...query } + return { page, setPage, search, setSearch, sortKey, sortOrder, setSort, ...query } } export function useCatalogMutations(url: string, listUrl: string) { diff --git a/src/food-market.web/src/pages/CounterpartiesPage.tsx b/src/food-market.web/src/pages/CounterpartiesPage.tsx index 7382984..e6431c0 100644 --- a/src/food-market.web/src/pages/CounterpartiesPage.tsx +++ b/src/food-market.web/src/pages/CounterpartiesPage.tsx @@ -48,7 +48,7 @@ const typeLabel: Record = { } export function CounterpartiesPage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) const { create, update, remove } = useCatalogMutations(URL, URL) const [form, setForm] = useState
(null) @@ -86,6 +86,9 @@ export function CounterpartiesPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => setForm({ id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type, bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '', @@ -94,12 +97,12 @@ export function CounterpartiesPage() { contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive, })} columns={[ - { header: 'Название', cell: (r) => r.name }, - { header: 'Тип', width: '120px', cell: (r) => typeLabel[r.type] }, + { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Тип', width: '120px', sortKey: 'type', cell: (r) => typeLabel[r.type] }, { header: 'БИН/ИИН', width: '140px', cell: (r) => {r.bin ?? r.iin ?? '—'} }, - { header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' }, - { header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' }, - { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + { header: 'Телефон', width: '160px', sortKey: 'phone', cell: (r) => r.phone ?? '—' }, + { header: 'Страна', width: '120px', sortKey: 'country', cell: (r) => r.countryName ?? '—' }, + { header: 'Активен', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, ]} /> diff --git a/src/food-market.web/src/pages/CountriesPage.tsx b/src/food-market.web/src/pages/CountriesPage.tsx index 117c46d..9cfcd22 100644 --- a/src/food-market.web/src/pages/CountriesPage.tsx +++ b/src/food-market.web/src/pages/CountriesPage.tsx @@ -24,7 +24,7 @@ interface Form { const blank: Form = { code: '', name: '', defaultCurrencyId: null, vatRate: 0 } export function CountriesPage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) const { create, update, remove } = useCatalogMutations(URL, URL) const currencies = useCurrencies() const [form, setForm] = useState(null) @@ -56,15 +56,18 @@ export function CountriesPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => setForm({ id: r.id, code: r.code, name: r.name, defaultCurrencyId: r.defaultCurrencyId, vatRate: r.vatRate, })} columns={[ - { header: 'Код', width: '80px', cell: (r) => {r.code} }, - { header: 'Название', cell: (r) => r.name }, - { header: 'Валюта', width: '120px', cell: (r) => r.defaultCurrencyCode ? `${r.defaultCurrencyCode} ${r.defaultCurrencySymbol ?? ''}` : '—' }, - { header: 'НДС', width: '100px', className: 'text-right', cell: (r) => `${r.vatRate.toFixed(2)}%` }, + { header: 'Код', width: '80px', sortKey: 'code', cell: (r) => {r.code} }, + { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Валюта', width: '120px', sortKey: 'currency', cell: (r) => r.defaultCurrencyCode ? `${r.defaultCurrencyCode} ${r.defaultCurrencySymbol ?? ''}` : '—' }, + { header: 'НДС', width: '100px', className: 'text-right', sortKey: 'vatRate', cell: (r) => `${r.vatRate.toFixed(2)}%` }, ]} /> diff --git a/src/food-market.web/src/pages/CurrenciesPage.tsx b/src/food-market.web/src/pages/CurrenciesPage.tsx index 510ebf7..803455f 100644 --- a/src/food-market.web/src/pages/CurrenciesPage.tsx +++ b/src/food-market.web/src/pages/CurrenciesPage.tsx @@ -6,7 +6,7 @@ import { useCatalogList } from '@/lib/useCatalog' import type { Currency } from '@/lib/types' export function CurrenciesPage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList('/api/catalog/currencies') + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList('/api/catalog/currencies') return ( r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} columns={[ - { header: 'Код', width: '90px', cell: (r) => {r.code} }, - { header: 'Название', cell: (r) => r.name }, - { header: 'Символ', width: '100px', cell: (r) => {r.symbol} }, + { header: 'Код', width: '90px', sortKey: 'code', cell: (r) => {r.code} }, + { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Символ', width: '100px', sortKey: 'symbol', cell: (r) => {r.symbol} }, { header: 'Знаки', width: '100px', className: 'text-right', cell: (r) => r.minorUnit }, - { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + { header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, ]} /> diff --git a/src/food-market.web/src/pages/PriceTypesPage.tsx b/src/food-market.web/src/pages/PriceTypesPage.tsx index 5fe1254..a68b86b 100644 --- a/src/food-market.web/src/pages/PriceTypesPage.tsx +++ b/src/food-market.web/src/pages/PriceTypesPage.tsx @@ -16,7 +16,7 @@ interface Form { id?: string; name: string; isDefault: boolean; isRetail: boolea const blankForm: Form = { name: '', isDefault: false, isRetail: false, sortOrder: 0, isActive: true } export function PriceTypesPage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) const { create, update, remove } = useCatalogMutations(URL, URL) const [form, setForm] = useState(null) @@ -47,13 +47,16 @@ export function PriceTypesPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => setForm({ ...r })} columns={[ - { header: 'Название', cell: (r) => r.name }, - { header: 'Розничная', width: '120px', cell: (r) => r.isRetail ? '✓' : '—' }, - { header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' }, + { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Розничная', width: '120px', sortKey: 'isRetail', cell: (r) => r.isRetail ? '✓' : '—' }, + { header: 'По умолчанию', width: '140px', sortKey: 'isDefault', cell: (r) => r.isDefault ? '✓' : '—' }, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, - { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + { header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, ]} /> diff --git a/src/food-market.web/src/pages/ProductGroupsPage.tsx b/src/food-market.web/src/pages/ProductGroupsPage.tsx index cb49924..10ad7b7 100644 --- a/src/food-market.web/src/pages/ProductGroupsPage.tsx +++ b/src/food-market.web/src/pages/ProductGroupsPage.tsx @@ -16,7 +16,7 @@ interface Form { id?: string; name: string; parentId: string | null; sortOrder: const blankForm: Form = { name: '', parentId: null, sortOrder: 0, isActive: true } export function ProductGroupsPage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) const { create, update, remove } = useCatalogMutations(URL, URL) const [form, setForm] = useState(null) @@ -47,12 +47,15 @@ export function ProductGroupsPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive })} columns={[ - { header: 'Название', cell: (r) => r.name }, - { header: 'Путь', cell: (r) => {r.path} }, + { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Путь', sortKey: 'path', cell: (r) => {r.path} }, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, - { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + { header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, ]} /> diff --git a/src/food-market.web/src/pages/ProductsPage.tsx b/src/food-market.web/src/pages/ProductsPage.tsx index c69efb4..bbd5ddc 100644 --- a/src/food-market.web/src/pages/ProductsPage.tsx +++ b/src/food-market.web/src/pages/ProductsPage.tsx @@ -96,7 +96,7 @@ 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 { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL, toExtra(filters)) const org = useOrgSettings() const showVat = org.data?.showVatEnabledOnProduct ?? false const activeCount = activeFilterCount(filters) @@ -105,24 +105,25 @@ export function ProductsPage() { header: string width?: string className?: string + sortKey?: string cell: (r: Product) => React.ReactNode } const baseColumns: Col[] = [ - { header: 'Название', cell: (r) => ( + { header: 'Название', sortKey: 'name', cell: (r) => (
{r.name}
{r.article &&
{r.article}
}
)}, - { header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' }, - { header: 'Ед.', width: '70px', cell: (r) => r.unitName }, + { header: 'Группа', width: '200px', sortKey: 'group', cell: (r) => r.productGroupName ?? '—' }, + { header: 'Ед.', width: '70px', sortKey: 'unit', cell: (r) => r.unitName }, ] if (showVat) { - baseColumns.push({ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => r.vatEnabled ? `${r.vat}%` : '—' }) + baseColumns.push({ header: 'НДС', width: '80px', className: 'text-right', sortKey: 'vat', cell: (r) => r.vatEnabled ? `${r.vat}%` : '—' }) } baseColumns.push( { header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length }, - { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + { header: 'Активен', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, ) return ( @@ -197,6 +198,9 @@ export function ProductsPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => navigate(`/catalog/products/${r.id}`)} columns={baseColumns} empty="Товаров ещё нет. Они появятся после приёмки или через API." diff --git a/src/food-market.web/src/pages/RetailPointsPage.tsx b/src/food-market.web/src/pages/RetailPointsPage.tsx index dad7d5b..4f4a45e 100644 --- a/src/food-market.web/src/pages/RetailPointsPage.tsx +++ b/src/food-market.web/src/pages/RetailPointsPage.tsx @@ -32,7 +32,7 @@ const blankForm = (storeId: string): Form => ({ }) export function RetailPointsPage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) const { create, update, remove } = useCatalogMutations(URL, URL) const [form, setForm] = useState(null) @@ -73,6 +73,9 @@ export function RetailPointsPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => setForm({ id: r.id, name: r.name, code: r.code ?? '', storeId: r.storeId, address: r.address ?? '', phone: r.phone ?? '', @@ -80,12 +83,12 @@ export function RetailPointsPage() { isActive: r.isActive, })} columns={[ - { header: 'Название', cell: (r) => r.name }, - { header: 'Код', width: '120px', cell: (r) => {r.code ?? '—'} }, - { header: 'Склад', cell: (r) => r.storeName ?? '—' }, - { header: 'Адрес', cell: (r) => r.address ?? '—' }, + { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Код', width: '120px', sortKey: 'code', cell: (r) => {r.code ?? '—'} }, + { header: 'Склад', sortKey: 'store', cell: (r) => r.storeName ?? '—' }, + { header: 'Адрес', sortKey: 'address', cell: (r) => r.address ?? '—' }, { header: 'ККМ', width: '140px', cell: (r) => r.fiscalSerial ?? '—' }, - { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + { header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, ]} /> diff --git a/src/food-market.web/src/pages/RetailSalesPage.tsx b/src/food-market.web/src/pages/RetailSalesPage.tsx index f730be7..a4726c3 100644 --- a/src/food-market.web/src/pages/RetailSalesPage.tsx +++ b/src/food-market.web/src/pages/RetailSalesPage.tsx @@ -20,7 +20,7 @@ const paymentLabel: Record = { export function RetailSalesPage() { const navigate = useNavigate() - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) return ( r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => navigate(`/sales/retail/${r.id}`)} columns={[ - { header: '№', width: '160px', cell: (r) => {r.number} }, - { header: 'Дата/время', width: '160px', cell: (r) => new Date(r.date).toLocaleString('ru') }, - { header: 'Статус', width: '120px', cell: (r) => ( + { header: '№', width: '160px', sortKey: 'number', cell: (r) => {r.number} }, + { header: 'Дата/время', width: '160px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleString('ru') }, + { header: 'Статус', width: '120px', sortKey: 'status', cell: (r) => ( r.status === RetailSaleStatus.Posted ? Проведён : Черновик )}, - { header: 'Магазин', cell: (r) => r.storeName }, + { header: 'Магазин', sortKey: 'store', cell: (r) => r.storeName }, { header: 'Касса', width: '160px', cell: (r) => r.retailPointName ?? '—' }, { header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? аноним }, { header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' }, { header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount }, - { header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` }, + { header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` }, ]} empty="Чеков пока нет. На Phase 5 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста." /> diff --git a/src/food-market.web/src/pages/StockMovementsPage.tsx b/src/food-market.web/src/pages/StockMovementsPage.tsx index ef9d0de..4ae2d7a 100644 --- a/src/food-market.web/src/pages/StockMovementsPage.tsx +++ b/src/food-market.web/src/pages/StockMovementsPage.tsx @@ -7,6 +7,7 @@ import { Select } from '@/components/Field' import { api } from '@/lib/api' import { useStores } from '@/lib/useLookups' import type { PagedResult, MovementRow } from '@/lib/types' +import type { SortOrder } from '@/components/DataTable' const typeLabels: Record = { Initial: 'Начальный', @@ -26,12 +27,16 @@ export function StockMovementsPage() { const stores = useStores() const [storeId, setStoreId] = useState('') const [page, setPage] = useState(1) + const [sortKey, setSortKey] = useState(null) + const [sortOrder, setSortOrder] = useState('asc') + const setSort = (key: string, order: SortOrder) => { setSortKey(key); setSortOrder(order); setPage(1) } const { data, isLoading } = useQuery({ - queryKey: ['/api/inventory/movements', { storeId, page }], + queryKey: ['/api/inventory/movements', { storeId, page, sortKey, sortOrder }], queryFn: async () => { const params = new URLSearchParams({ page: String(page), pageSize: '50' }) if (storeId) params.set('storeId', storeId) + if (sortKey) { params.set('sort', sortKey); params.set('order', sortOrder) } return (await api.get>(`/api/inventory/movements?${params}`)).data }, placeholderData: (prev) => prev, @@ -57,22 +62,25 @@ export function StockMovementsPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} columns={[ - { header: 'Дата', width: '160px', cell: (r) => new Date(r.occurredAt).toLocaleString('ru') }, - { header: 'Операция', width: '160px', cell: (r) => typeLabels[r.type] ?? r.type }, - { header: 'Товар', cell: (r) => ( + { header: 'Дата', width: '160px', sortKey: 'occurredAt', cell: (r) => new Date(r.occurredAt).toLocaleString('ru') }, + { header: 'Операция', width: '160px', sortKey: 'type', cell: (r) => typeLabels[r.type] ?? r.type }, + { header: 'Товар', sortKey: 'product', cell: (r) => (
{r.productName}
{r.article &&
{r.article}
}
)}, - { header: 'Склад', width: '180px', cell: (r) => r.storeName }, - { header: 'Количество', width: '140px', className: 'text-right font-mono', cell: (r) => ( + { header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName }, + { header: 'Количество', width: '140px', className: 'text-right font-mono', sortKey: 'quantity', cell: (r) => ( 0 ? 'text-green-700' : r.quantity < 0 ? 'text-red-600' : ''}> {r.quantity > 0 ? '+' : ''}{r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 })} )}, - { header: 'Документ', width: '200px', cell: (r) => r.documentNumber ? {r.documentType} · {r.documentNumber} : }, + { header: 'Документ', width: '200px', sortKey: 'documentType', cell: (r) => r.documentNumber ? {r.documentType} · {r.documentNumber} : }, ]} empty="Движений ещё нет." /> diff --git a/src/food-market.web/src/pages/StockPage.tsx b/src/food-market.web/src/pages/StockPage.tsx index 6d00b3f..20fc099 100644 --- a/src/food-market.web/src/pages/StockPage.tsx +++ b/src/food-market.web/src/pages/StockPage.tsx @@ -8,6 +8,7 @@ import { Select, Checkbox } from '@/components/Field' import { api } from '@/lib/api' import { useStores } from '@/lib/useLookups' import type { PagedResult, StockRow } from '@/lib/types' +import type { SortOrder } from '@/components/DataTable' export function StockPage() { const stores = useStores() @@ -15,14 +16,18 @@ export function StockPage() { const [search, setSearch] = useState('') const [includeZero, setIncludeZero] = useState(false) const [page, setPage] = useState(1) + const [sortKey, setSortKey] = useState(null) + const [sortOrder, setSortOrder] = useState('asc') + const setSort = (key: string, order: SortOrder) => { setSortKey(key); setSortOrder(order); setPage(1) } const { data, isLoading } = useQuery({ - queryKey: ['/api/inventory/stock', { storeId, search, includeZero, page }], + queryKey: ['/api/inventory/stock', { storeId, search, includeZero, page, sortKey, sortOrder }], queryFn: async () => { const params = new URLSearchParams({ page: String(page), pageSize: '50' }) if (storeId) params.set('storeId', storeId) if (search) params.set('search', search) if (includeZero) params.set('includeZero', 'true') + if (sortKey) { params.set('sort', sortKey); params.set('order', sortOrder) } return (await api.get>(`/api/inventory/stock?${params}`)).data }, placeholderData: (prev) => prev, @@ -52,18 +57,21 @@ export function StockPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => `${r.productId}:${r.storeId}`} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} columns={[ - { header: 'Товар', cell: (r) => ( + { header: 'Товар', sortKey: 'name', cell: (r) => (
{r.productName}
{r.article &&
{r.article}
}
)}, - { header: 'Склад', width: '220px', cell: (r) => r.storeName }, - { header: 'Ед.', width: '80px', cell: (r) => r.unitName }, - { header: 'Остаток', width: '130px', className: 'text-right font-mono', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) }, - { header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' }, - { header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', cell: (r) => ( + { header: 'Склад', width: '220px', sortKey: 'store', cell: (r) => r.storeName }, + { header: 'Ед.', width: '80px', sortKey: 'unit', cell: (r) => r.unitName }, + { header: 'Остаток', width: '130px', className: 'text-right font-mono', sortKey: 'quantity', cell: (r) => r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 }) }, + { header: 'Резерв', width: '110px', className: 'text-right font-mono text-slate-400', sortKey: 'reserved', cell: (r) => r.reservedQuantity ? r.reservedQuantity.toLocaleString('ru') : '—' }, + { header: 'Доступно', width: '130px', className: 'text-right font-mono font-semibold', sortKey: 'available', cell: (r) => ( {r.available.toLocaleString('ru', { maximumFractionDigits: 3 })} diff --git a/src/food-market.web/src/pages/StoresPage.tsx b/src/food-market.web/src/pages/StoresPage.tsx index b6f5714..2242720 100644 --- a/src/food-market.web/src/pages/StoresPage.tsx +++ b/src/food-market.web/src/pages/StoresPage.tsx @@ -29,7 +29,7 @@ const blankForm: Form = { } export function StoresPage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) const { create, update, remove } = useCatalogMutations(URL, URL) const [form, setForm] = useState(null) @@ -60,17 +60,20 @@ export function StoresPage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => setForm({ id: r.id, name: r.name, code: r.code ?? '', address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '', isMain: r.isMain, isActive: r.isActive, })} columns={[ - { header: 'Название', cell: (r) => r.name }, - { header: 'Код', width: '120px', cell: (r) => {r.code ?? '—'} }, - { header: 'Адрес', cell: (r) => r.address ?? '—' }, - { header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' }, - { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + { header: 'Название', sortKey: 'name', cell: (r) => r.name }, + { header: 'Код', width: '120px', sortKey: 'code', cell: (r) => {r.code ?? '—'} }, + { header: 'Адрес', sortKey: 'address', cell: (r) => r.address ?? '—' }, + { header: 'Основной', width: '110px', sortKey: 'isMain', cell: (r) => r.isMain ? '✓' : '—' }, + { header: 'Активен', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, ]} />
diff --git a/src/food-market.web/src/pages/SuppliesPage.tsx b/src/food-market.web/src/pages/SuppliesPage.tsx index 31d9d57..462224b 100644 --- a/src/food-market.web/src/pages/SuppliesPage.tsx +++ b/src/food-market.web/src/pages/SuppliesPage.tsx @@ -12,7 +12,7 @@ const URL = '/api/purchases/supplies' export function SuppliesPage() { const navigate = useNavigate() - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) return ( r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => navigate(`/purchases/supplies/${r.id}`)} columns={[ - { header: '№', width: '160px', cell: (r) => {r.number} }, - { header: 'Дата', width: '120px', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, - { header: 'Статус', width: '130px', cell: (r) => ( + { header: '№', width: '160px', sortKey: 'number', cell: (r) => {r.number} }, + { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, + { header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => ( r.status === SupplyStatus.Posted ? Проведён : Черновик )}, - { header: 'Поставщик', cell: (r) => r.supplierName }, - { header: 'Склад', width: '180px', cell: (r) => r.storeName }, + { header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName }, + { header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName }, { header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, - { header: 'Сумма', width: '160px', className: 'text-right font-mono', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` }, + { header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` }, ]} empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения." /> diff --git a/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx b/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx index fa19ef1..65fcaf8 100644 --- a/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx +++ b/src/food-market.web/src/pages/UnitsOfMeasurePage.tsx @@ -23,7 +23,7 @@ interface Form { const blankForm: Form = { code: '', name: '', description: '', isActive: true } export function UnitsOfMeasurePage() { - const { data, isLoading, page, setPage, search, setSearch } = useCatalogList(URL) + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) const { create, update, remove } = useCatalogMutations(URL, URL) const [form, setForm] = useState(null) @@ -54,15 +54,18 @@ export function UnitsOfMeasurePage() { rows={data?.items ?? []} isLoading={isLoading} rowKey={(r) => r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} onRowClick={(r) => setForm({ id: r.id, code: r.code, name: r.name, description: r.description ?? '', isActive: r.isActive, })} columns={[ - { header: 'Код', width: '90px', cell: (r) => {r.code} }, - { header: 'Название', cell: (r) => r.name }, + { header: 'Код', width: '90px', sortKey: 'code', cell: (r) => {r.code} }, + { header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Описание', cell: (r) => r.description ?? '—' }, - { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + { header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, ]} />