feat(tables): server-side sort by column header click

Во всех таблицах можно сортировать по клику на заголовок столбца:
первый клик — по возрастанию (↑), второй — по убыванию (↓),
смена колонки сбрасывает предыдущую. Без активной сортировки —
серверный 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:
nns 2026-04-24 12:24:05 +05:00
parent 31d528d5c2
commit 5a06e15924
28 changed files with 356 additions and 93 deletions

View file

@ -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,

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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."

View file

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

View file

@ -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 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
/>

View file

@ -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="Движений ещё нет."
/>

View file

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

View file

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

View file

@ -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="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
/>

View file

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