feat(tables): server-side sort by column header click
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 26s
CI / Web (React + Vite) (push) Successful in 22s
Docker Images / API image (push) Successful in 38s
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 26s
CI / Web (React + Vite) (push) Successful in 22s
Docker Images / API image (push) Successful in 38s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s
Во всех таблицах можно сортировать по клику на заголовок столбца:
первый клик — по возрастанию (↑), второй — по убыванию (↓),
смена колонки сбрасывает предыдущую. Без активной сортировки —
серверный 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) <noreply@anthropic.com>
This commit is contained in:
parent
4f4df4a715
commit
6cd9e27553
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,8 +27,18 @@ public async Task<ActionResult<PagedResult<CountryDto>>> 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,
|
||||
|
|
|
|||
|
|
@ -27,8 +27,18 @@ public async Task<ActionResult<PagedResult<CurrencyDto>>> 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);
|
||||
|
|
|
|||
|
|
@ -27,8 +27,19 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> 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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -84,8 +84,22 @@ private async Task<int> 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);
|
||||
|
|
|
|||
|
|
@ -27,8 +27,20 @@ public async Task<ActionResult<PagedResult<RetailPointDto>>> 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))
|
||||
|
|
|
|||
|
|
@ -27,8 +27,21 @@ public async Task<ActionResult<PagedResult<StoreDto>>> 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);
|
||||
|
|
|
|||
|
|
@ -27,8 +27,16 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
/// <summary>Ключ колонки, по которой сортировать (см. switch в контроллере).</summary>
|
||||
public string? Sort { get; init; }
|
||||
/// <summary>"asc" (дефолт) или "desc".</summary>
|
||||
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<T>
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
header: string
|
||||
cell: (row: T) => ReactNode
|
||||
className?: string
|
||||
width?: string
|
||||
/** Если задан — заголовок становится кликабельным, при клике вызывает onSortChange. */
|
||||
sortKey?: string
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
|
|
@ -18,11 +23,42 @@ interface DataTableProps<T> {
|
|||
/** 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<T>({
|
||||
rows, columns, rowKey, onRowClick, empty, isLoading, scrollable = true,
|
||||
sortKey, sortOrder, onSortChange,
|
||||
}: DataTableProps<T>) {
|
||||
const handleHeaderClick = (key: string) => {
|
||||
if (!onSortChange) return
|
||||
const nextOrder: SortOrder = sortKey === key && sortOrder === 'asc' ? 'desc' : 'asc'
|
||||
onSortChange(key, nextOrder)
|
||||
}
|
||||
|
||||
const renderHeader = (c: Column<T>) => {
|
||||
if (!c.sortKey || !onSortChange) return c.header
|
||||
const active = sortKey === c.sortKey
|
||||
const Icon = active ? (sortOrder === 'desc' ? ArrowDown : ArrowUp) : ArrowUpDown
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleHeaderClick(c.sortKey!)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 select-none',
|
||||
active ? 'text-slate-900 dark:text-slate-100' : 'hover:text-slate-700 dark:hover:text-slate-300',
|
||||
)}
|
||||
>
|
||||
<span>{c.header}</span>
|
||||
<Icon className={cn('w-3 h-3', active ? 'opacity-100' : 'opacity-40')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const table = (
|
||||
<table className="w-full text-sm border-separate border-spacing-0">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50 dark:bg-slate-800/90 backdrop-blur text-left">
|
||||
|
|
@ -36,7 +72,7 @@ export function DataTable<T>({
|
|||
)}
|
||||
style={c.width ? { width: c.width } : undefined}
|
||||
>
|
||||
{c.header}
|
||||
{renderHeader(c)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -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<T>(url: string, extraParams: Record<string, string | number | boolean | undefined> = {}) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('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<T>(url: string, extraParams: Record<string, strin
|
|||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
return { page, setPage, search, setSearch, ...query }
|
||||
return { page, setPage, search, setSearch, sortKey, sortOrder, setSort, ...query }
|
||||
}
|
||||
|
||||
export function useCatalogMutations(url: string, listUrl: string) {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const typeLabel: Record<CounterpartyType, string> = {
|
|||
}
|
||||
|
||||
export function CounterpartiesPage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Counterparty>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Counterparty>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(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) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
|
||||
{ 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 ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
|
|
@ -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<Country>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Country>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const currencies = useCurrencies()
|
||||
const [form, setForm] = useState<Form | null>(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) => <span className="font-mono">{r.code}</span> },
|
||||
{ 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) => <span className="font-mono">{r.code}</span> },
|
||||
{ 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)}%` },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
|
|
@ -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<Currency>('/api/catalog/currencies')
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Currency>('/api/catalog/currencies')
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
|
|
@ -21,12 +21,15 @@ export function CurrenciesPage() {
|
|||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
columns={[
|
||||
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: 'Символ', width: '100px', cell: (r) => <span className="text-lg">{r.symbol}</span> },
|
||||
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ header: 'Символ', width: '100px', sortKey: 'symbol', cell: (r) => <span className="text-lg">{r.symbol}</span> },
|
||||
{ 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 ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
|
|
@ -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<PriceType>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<PriceType>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(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 ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
|
|
@ -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<ProductGroup>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(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) => <span className="text-slate-500">{r.path}</span> },
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
|
||||
{ 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 ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ 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 { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Product>(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) => (
|
||||
<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: '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."
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const blankForm = (storeId: string): Form => ({
|
|||
})
|
||||
|
||||
export function RetailPointsPage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<RetailPoint>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<RetailPoint>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(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) => <span className="font-mono">{r.code ?? '—'}</span> },
|
||||
{ header: 'Склад', cell: (r) => r.storeName ?? '—' },
|
||||
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ header: 'Код', width: '120px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
|
||||
{ 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 ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const paymentLabel: Record<number, string> = {
|
|||
|
||||
export function RetailSalesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<RetailSaleListRow>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<RetailSaleListRow>(URL)
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
|
|
@ -42,21 +42,24 @@ export function RetailSalesPage() {
|
|||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
onRowClick={(r) => navigate(`/sales/retail/${r.id}`)}
|
||||
columns={[
|
||||
{ header: '№', width: '160px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
|
||||
{ header: 'Дата/время', width: '160px', cell: (r) => new Date(r.date).toLocaleString('ru') },
|
||||
{ header: 'Статус', width: '120px', cell: (r) => (
|
||||
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
|
||||
{ header: 'Дата/время', width: '160px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleString('ru') },
|
||||
{ header: 'Статус', width: '120px', sortKey: 'status', cell: (r) => (
|
||||
r.status === RetailSaleStatus.Posted
|
||||
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
|
||||
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
|
||||
)},
|
||||
{ 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 ?? <span className="text-slate-400">аноним</span> },
|
||||
{ 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 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
Initial: 'Начальный',
|
||||
|
|
@ -26,12 +27,16 @@ export function StockMovementsPage() {
|
|||
const stores = useStores()
|
||||
const [storeId, setStoreId] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('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<PagedResult<MovementRow>>(`/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) => (
|
||||
<div>
|
||||
<div className="font-medium">{r.productName}</div>
|
||||
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
||||
</div>
|
||||
)},
|
||||
{ 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) => (
|
||||
<span className={r.quantity > 0 ? 'text-green-700' : r.quantity < 0 ? 'text-red-600' : ''}>
|
||||
{r.quantity > 0 ? '+' : ''}{r.quantity.toLocaleString('ru', { maximumFractionDigits: 3 })}
|
||||
</span>
|
||||
)},
|
||||
{ header: 'Документ', width: '200px', cell: (r) => r.documentNumber ? <span className="text-slate-500">{r.documentType} · {r.documentNumber}</span> : <span className="text-slate-400">—</span> },
|
||||
{ header: 'Документ', width: '200px', sortKey: 'documentType', cell: (r) => r.documentNumber ? <span className="text-slate-500">{r.documentType} · {r.documentNumber}</span> : <span className="text-slate-400">—</span> },
|
||||
]}
|
||||
empty="Движений ещё нет."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('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<PagedResult<StockRow>>(`/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) => (
|
||||
<div>
|
||||
<div className="font-medium">{r.productName}</div>
|
||||
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
|
||||
</div>
|
||||
)},
|
||||
{ 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) => (
|
||||
<span className={r.available < 0 ? 'text-red-600' : r.available === 0 ? 'text-slate-400' : ''}>
|
||||
{r.available.toLocaleString('ru', { maximumFractionDigits: 3 })}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const blankForm: Form = {
|
|||
}
|
||||
|
||||
export function StoresPage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Store>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Store>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(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) => <span className="font-mono">{r.code ?? '—'}</span> },
|
||||
{ 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) => <span className="font-mono">{r.code ?? '—'}</span> },
|
||||
{ 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 ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const URL = '/api/purchases/supplies'
|
|||
|
||||
export function SuppliesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<SupplyListRow>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<SupplyListRow>(URL)
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
|
|
@ -34,19 +34,22 @@ export function SuppliesPage() {
|
|||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
onRowClick={(r) => navigate(`/purchases/supplies/${r.id}`)}
|
||||
columns={[
|
||||
{ header: '№', width: '160px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
|
||||
{ header: 'Дата', width: '120px', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '130px', cell: (r) => (
|
||||
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => (
|
||||
r.status === SupplyStatus.Posted
|
||||
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
|
||||
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
|
||||
)},
|
||||
{ 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="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<UnitOfMeasure>(URL)
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<UnitOfMeasure>(URL)
|
||||
const { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
const [form, setForm] = useState<Form | null>(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) => <span className="font-mono">{r.code}</span> },
|
||||
{ header: 'Название', cell: (r) => r.name },
|
||||
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||
{ 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 ? '✓' : '—' },
|
||||
]}
|
||||
/>
|
||||
</ListPageShell>
|
||||
|
|
|
|||
Loading…
Reference in a new issue