feat(dashboard): sales chart + KPIs (как «Показатели» в МойСклад)

API: GET /api/sales/retail/stats?days=30 — возвращает:
- revenueToday + transactionsToday
- revenueThisMonth + transactionsThisMonth + avgTicketThisMonth
- revenuePrevMonth (для сравнения месяц-к-месяцу)
- series — массив дневных точек {bucket, revenue, transactions} с заполнением
  пустых дней нулями (чтобы линия графика была непрерывной)
- считает только проведённые чеки (Status == Posted)

Web:
- recharts добавлен (3.8.1)
- SalesChart компонент: AreaChart с градиент-заливкой брендового зелёного,
  ось X — дни (DD.MM), ось Y — выручка, tooltip с числами и валютой
- DashboardPage пересобран под продажи как первичную инфу:
  - 4 KPI-карточки сверху: выручка сегодня, выручка за месяц (с дельтой
    к прошлому месяцу), средний чек, прошлый месяц
  - график за 30 дней с empty-state когда чеков нет
  - Каталог теперь второстепенный (мелкие карточки внизу)

Empty-state: если за 30 дней не было ни одной продажи — показываем
"График появится когда появятся первые продажи" вместо плоской линии.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-23 09:57:35 +05:00
parent a5f7060fb1
commit 7640d6ddcd
6 changed files with 582 additions and 46 deletions

View file

@ -54,6 +54,75 @@ public record RetailSaleInput(
string? Notes, string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines); IReadOnlyList<RetailSaleLineInput> Lines);
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
public record SalesStatsResponse(
decimal RevenueToday,
decimal RevenueThisMonth,
decimal RevenuePrevMonth,
int TransactionsToday,
int TransactionsThisMonth,
decimal AvgTicketThisMonth,
IReadOnlyList<SalesStatsBucket> Series);
/// <summary>Aggregated sales metrics + daily series for the dashboard.
/// Series buckets are days; defaults to last 30 days.</summary>
[HttpGet("stats")]
public async Task<ActionResult<SalesStatsResponse>> Stats(
[FromQuery] int days = 30,
CancellationToken ct = default)
{
var nowUtc = DateTime.UtcNow;
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc);
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var prevMonthStart = monthStart.AddMonths(-1);
var seriesStart = todayStart.AddDays(-(days - 1));
var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted);
var today = await posted.Where(s => s.Date >= todayStart && s.Date < todayStart.AddDays(1))
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
.FirstOrDefaultAsync(ct);
var thisMonth = await posted.Where(s => s.Date >= monthStart)
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
.FirstOrDefaultAsync(ct);
var prevMonth = await posted.Where(s => s.Date >= prevMonthStart && s.Date < monthStart)
.GroupBy(_ => 1)
.Select(g => new { Sum = g.Sum(s => s.Total) })
.FirstOrDefaultAsync(ct);
var rawSeries = await posted.Where(s => s.Date >= seriesStart)
.GroupBy(s => s.Date.Date)
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() })
.ToListAsync(ct);
// Fill missing days with zeros so the chart line is continuous.
var byDay = rawSeries.ToDictionary(x => x.Day, x => x);
var series = Enumerable.Range(0, days)
.Select(i => seriesStart.AddDays(i).Date)
.Select(d => byDay.TryGetValue(d, out var v)
? new SalesStatsBucket(d, v.Revenue, v.Tx)
: new SalesStatsBucket(d, 0m, 0))
.ToList();
var thisMonthSum = thisMonth?.Sum ?? 0m;
var thisMonthCount = thisMonth?.Count ?? 0;
var avgTicket = thisMonthCount == 0 ? 0m : thisMonthSum / thisMonthCount;
return new SalesStatsResponse(
RevenueToday: today?.Sum ?? 0m,
RevenueThisMonth: thisMonthSum,
RevenuePrevMonth: prevMonth?.Sum ?? 0m,
TransactionsToday: today?.Count ?? 0,
TransactionsThisMonth: thisMonthCount,
AvgTicketThisMonth: avgTicket,
Series: series);
}
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<RetailSaleListRow>>> List( public async Task<ActionResult<PagedResult<RetailSaleListRow>>> List(
[FromQuery] PagedRequest req, [FromQuery] PagedRequest req,

View file

@ -21,6 +21,7 @@
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-hook-form": "^7.73.1", "react-hook-form": "^7.73.1",
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^4.3.6" "zod": "^4.3.6"

View file

@ -41,6 +41,9 @@ importers:
react-router-dom: react-router-dom:
specifier: ^7.14.1 specifier: ^7.14.1
version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.5)(react@19.2.5)(redux@5.0.1)
tailwind-merge: tailwind-merge:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.5.0 version: 3.5.0
@ -266,6 +269,17 @@ packages:
'@oxc-project/types@0.126.0': '@oxc-project/types@0.126.0':
resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.0.0-rc.16': '@rolldown/binding-android-arm64@1.0.0-rc.16':
resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==} resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -367,6 +381,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7': '@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@ -484,6 +501,33 @@ packages:
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -501,6 +545,9 @@ packages:
'@types/react@19.2.14': '@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.59.0': '@typescript-eslint/eslint-plugin@8.59.0':
resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -680,6 +727,50 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@ -689,6 +780,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -727,6 +821,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.46.0:
resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==}
escalade@3.2.0: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -792,6 +889,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -910,6 +1010,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -918,6 +1024,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-extglob@2.1.1: is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -1152,6 +1262,21 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
react-is@19.2.5:
resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-router-dom@7.14.1: react-router-dom@7.14.1:
resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==} resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@ -1173,6 +1298,25 @@ packages:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -1232,6 +1376,9 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'} engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.16: tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -1273,6 +1420,14 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@8.0.9: vite@8.0.9:
resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==} resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1554,6 +1709,18 @@ snapshots:
'@oxc-project/types@0.126.0': {} '@oxc-project/types@0.126.0': {}
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.5
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.0.0-rc.16': '@rolldown/binding-android-arm64@1.0.0-rc.16':
optional: true optional: true
@ -1607,6 +1774,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.7': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.2.3': '@tailwindcss/node@4.2.3':
@ -1697,6 +1866,30 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@ -1713,6 +1906,8 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)': '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@ -1914,10 +2109,50 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js-light@2.5.1: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
@ -1952,6 +2187,8 @@ snapshots:
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
hasown: 2.0.3 hasown: 2.0.3
es-toolkit@1.46.0: {}
escalade@3.2.0: {} escalade@3.2.0: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
@ -2041,6 +2278,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@5.0.4: {}
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
@ -2138,6 +2377,10 @@ snapshots:
ignore@7.0.5: {} ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.4: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@ -2145,6 +2388,8 @@ snapshots:
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
internmap@2.0.3: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
is-glob@4.0.3: is-glob@4.0.3:
@ -2323,6 +2568,17 @@ snapshots:
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
react-is@19.2.5: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react-router-dom@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5): react-router-dom@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
@ -2339,6 +2595,34 @@ snapshots:
react@19.2.5: {} react@19.2.5: {}
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.5)(react@19.2.5)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.46.0
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-is: 19.2.5
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.5)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
rolldown@1.0.0-rc.16: rolldown@1.0.0-rc.16:
@ -2394,6 +2678,8 @@ snapshots:
tapable@2.3.2: {} tapable@2.3.2: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.16: tinyglobby@0.2.16:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
@ -2435,6 +2721,27 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.5):
dependencies:
react: 19.2.5
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@8.0.9(@types/node@24.12.2)(jiti@2.6.1): vite@8.0.9(@types/node@24.12.2)(jiti@2.6.1):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0

View file

@ -0,0 +1,77 @@
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid,
} from 'recharts'
import type { SalesStatsBucket } from '@/lib/types'
interface Props {
series: SalesStatsBucket[]
currencyCode?: string
}
const fmt = new Intl.NumberFormat('ru', { maximumFractionDigits: 0 })
const fmtDay = (s: string) => {
const d = new Date(s)
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}`
}
export function SalesChart({ series, currencyCode = 'KZT' }: Props) {
const data = series.map((b) => ({
day: fmtDay(b.bucket),
revenue: b.revenue,
transactions: b.transactions,
}))
return (
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="revenue-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-brand)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-brand)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis
dataKey="day"
stroke="#94a3b8"
fontSize={11}
interval="preserveStartEnd"
tickLine={false}
/>
<YAxis
stroke="#94a3b8"
fontSize={11}
tickFormatter={(v) => fmt.format(v)}
tickLine={false}
axisLine={false}
width={70}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(15, 23, 42, 0.95)',
border: 'none',
borderRadius: '8px',
color: '#f1f5f9',
fontSize: '12px',
}}
labelStyle={{ color: '#94a3b8', marginBottom: 4 }}
formatter={(value, name) => {
const num = typeof value === 'number' ? value : Number(value ?? 0)
if (name === 'revenue') return [`${fmt.format(num)} ${currencyCode}`, 'Выручка']
return [String(value), String(name)]
}}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="var(--color-brand)"
strokeWidth={2}
fill="url(#revenue-fill)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}

View file

@ -121,6 +121,22 @@ export interface RetailSaleLineDto {
vatPercent: number; sortOrder: number; vatPercent: number; sortOrder: number;
} }
export interface SalesStatsBucket {
bucket: string
revenue: number
transactions: number
}
export interface SalesStatsResponse {
revenueToday: number
revenueThisMonth: number
revenuePrevMonth: number
transactionsToday: number
transactionsThisMonth: number
avgTicketThisMonth: number
series: SalesStatsBucket[]
}
export interface RetailSaleDto { export interface RetailSaleDto {
id: string; number: string; date: string; status: RetailSaleStatus; id: string; number: string; date: string; status: RetailSaleStatus;
storeId: string; storeName: string; storeId: string; storeName: string;

View file

@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Package, Users, Warehouse, Store } from 'lucide-react' import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { SalesChart } from '@/components/SalesChart'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import type { PagedResult } from '@/lib/types' import type { PagedResult, SalesStatsResponse } from '@/lib/types'
interface MeResponse { interface MeResponse {
sub: string sub: string
@ -19,22 +20,54 @@ function useCount(url: string) {
}) })
} }
interface StatCardProps { const fmt = new Intl.NumberFormat('ru', { maximumFractionDigits: 0 })
const fmtMoney = (n: number) => fmt.format(n)
interface KpiCardProps {
icon: React.ComponentType<{ className?: string }> icon: React.ComponentType<{ className?: string }>
label: string label: string
value: number | string | undefined value: string | number
isLoading: boolean hint?: string
delta?: { value: number; positive: boolean }
} }
function StatCard({ icon: Icon, label, value, isLoading }: StatCardProps) { function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
return ( return (
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5"> <div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-slate-900 dark:text-slate-100 truncate">
{value}
</div>
{hint && <div className="mt-0.5 text-xs text-slate-400">{hint}</div>}
</div>
<Icon className="w-5 h-5 text-slate-400 flex-shrink-0" />
</div>
{delta && (
<div className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${delta.positive ? 'text-green-600' : 'text-red-600'}`}>
{delta.positive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{delta.positive ? '+' : ''}{delta.value.toFixed(1)}% к прошлому месяцу
</div>
)}
</div>
)
}
function MiniCard({ icon: Icon, label, value, isLoading }: {
icon: React.ComponentType<{ className?: string }>
label: string
value: number | undefined
isLoading: boolean
}) {
return (
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-slate-500">{label}</span> <span className="text-xs text-slate-500">{label}</span>
<Icon className="w-4 h-4 text-slate-400" /> <Icon className="w-4 h-4 text-slate-400" />
</div> </div>
<div className="text-2xl font-semibold mt-2 text-slate-900 dark:text-slate-100"> <div className="text-xl font-semibold mt-1.5 text-slate-900 dark:text-slate-100">
{isLoading ? '…' : value ?? '—'} {isLoading ? '…' : value !== undefined ? fmt.format(value) : '—'}
</div> </div>
</div> </div>
) )
@ -45,57 +78,90 @@ export function DashboardPage() {
queryKey: ['me'], queryKey: ['me'],
queryFn: async () => (await api.get<MeResponse>('/api/me')).data, queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
}) })
const stats = useQuery({
queryKey: ['/api/sales/retail/stats'],
queryFn: async () => (await api.get<SalesStatsResponse>('/api/sales/retail/stats?days=30')).data,
})
const products = useCount('/api/catalog/products') const products = useCount('/api/catalog/products')
const counterparties = useCount('/api/catalog/counterparties') const counterparties = useCount('/api/catalog/counterparties')
const stores = useCount('/api/catalog/stores') const stores = useCount('/api/catalog/stores')
const retailPoints = useCount('/api/catalog/retail-points') const retailPoints = useCount('/api/catalog/retail-points')
const anyError = [products, counterparties, stores, retailPoints].find(q => q.error)?.error as Error | undefined const monthDelta = stats.data && stats.data.revenuePrevMonth > 0
? ((stats.data.revenueThisMonth - stats.data.revenuePrevMonth) / stats.data.revenuePrevMonth) * 100
: null
const hasAnySales = stats.data && stats.data.series.some((b) => b.revenue > 0)
return ( return (
<div className="p-6"> <div className="p-6 space-y-6 overflow-auto">
<PageHeader <PageHeader
title="Dashboard" title="Dashboard"
description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Общие показатели системы'} description={me.data ? `Добро пожаловать, ${me.data.name}` : 'Сводка по продажам и каталогу'}
/> />
{anyError && ( {/* KPI блок продажи */}
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3 text-sm text-amber-800 dark:text-amber-200"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="font-medium">API недоступен или ещё не обновился</div> <KpiCard
<div className="text-amber-700 dark:text-amber-300 text-xs mt-0.5"> icon={Banknote}
Перезапусти API после git pull: <code className="font-mono">Ctrl+C dotnet run --project src/food-market.api</code> label="Выручка сегодня"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueToday ?? 0)}`}
hint={`${stats.data?.transactionsToday ?? 0} чеков`}
/>
<KpiCard
icon={Calendar}
label="Выручка за месяц"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisMonth ?? 0)}`}
hint={`${stats.data?.transactionsThisMonth ?? 0} чеков`}
delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0 } : undefined}
/>
<KpiCard
icon={Receipt}
label="Средний чек"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.avgTicketThisMonth ?? 0)}`}
hint="за месяц"
/>
<KpiCard
icon={TrendingUp}
label="Прошлый месяц"
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenuePrevMonth ?? 0)}`}
hint="для сравнения"
/>
</div> </div>
</div>
)}
{/* График продаж */}
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">Выручка за 30 дней</h2>
<p className="text-xs text-slate-500 mt-0.5">Сумма продаж по дням, проведённые чеки</p>
</div>
</div>
{stats.isLoading ? (
<div className="h-72 flex items-center justify-center text-slate-400 text-sm">Загрузка</div>
) : !hasAnySales ? (
<div className="h-72 flex flex-col items-center justify-center text-slate-400 text-sm gap-2">
<Receipt className="w-8 h-8 text-slate-300" />
<div>Чеков пока нет.</div>
<div className="text-xs">График появится когда появятся первые продажи.</div>
</div>
) : (
<SalesChart series={stats.data!.series} currencyCode="₸" />
)}
</section>
{/* Каталог */}
<div>
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 uppercase tracking-wide">
Каталог
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} /> <MiniCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
<StatCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} /> <MiniCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
<StatCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} /> <MiniCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
<StatCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} /> <MiniCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
</div>
</div> </div>
{me.data && (
<section className="mt-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100 mb-2.5">Текущий пользователь</h2>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
<div><dt className="text-slate-500 inline">Имя: </dt><dd className="inline text-slate-900 dark:text-slate-100 font-medium">{me.data.name}</dd></div>
<div><dt className="text-slate-500 inline">Email: </dt><dd className="inline">{me.data.email}</dd></div>
<div><dt className="text-slate-500 inline">Роли: </dt><dd className="inline">{me.data.roles.join(', ')}</dd></div>
<div><dt className="text-slate-500 inline">Организация: </dt><dd className="inline font-mono text-xs">{me.data.orgId}</dd></div>
</dl>
</section>
)}
<section className="mt-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-6">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 mb-2">Что дальше</h2>
<ul className="text-sm text-slate-600 dark:text-slate-300 space-y-1.5 list-disc list-inside">
<li>Phase 2: приёмка товара, розничные продажи, складские остатки</li>
<li>Phase 3: инвентаризация, списание, оприходование, возвраты поставщикам</li>
<li>Phase 4: ценники, отчёты P&L, бонусная система, аудит</li>
<li>Phase 5: Windows-касса + синхронизация + весы</li>
</ul>
</section>
</div> </div>
) )
} }