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:
parent
a5f7060fb1
commit
7640d6ddcd
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
77
src/food-market.web/src/components/SalesChart.tsx
Normal file
77
src/food-market.web/src/components/SalesChart.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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="Выручка сегодня"
|
||||||
</div>
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueToday ?? 0)} ₸`}
|
||||||
</div>
|
hint={`${stats.data?.transactionsToday ?? 0} чеков`}
|
||||||
)}
|
/>
|
||||||
|
<KpiCard
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
icon={Calendar}
|
||||||
<StatCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
|
label="Выручка за месяц"
|
||||||
<StatCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
|
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisMonth ?? 0)} ₸`}
|
||||||
<StatCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
|
hint={`${stats.data?.transactionsThisMonth ?? 0} чеков`}
|
||||||
<StatCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
|
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>
|
||||||
|
|
||||||
{me.data && (
|
{/* График продаж */}
|
||||||
<section className="mt-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
|
<section className="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>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
|
<div>
|
||||||
<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>
|
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100">Выручка за 30 дней</h2>
|
||||||
<div><dt className="text-slate-500 inline">Email: </dt><dd className="inline">{me.data.email}</dd></div>
|
<p className="text-xs text-slate-500 mt-0.5">Сумма продаж по дням, проведённые чеки</p>
|
||||||
<div><dt className="text-slate-500 inline">Роли: </dt><dd className="inline">{me.data.roles.join(', ')}</dd></div>
|
</div>
|
||||||
<div><dt className="text-slate-500 inline">Организация: </dt><dd className="inline font-mono text-xs">{me.data.orgId}</dd></div>
|
</div>
|
||||||
</dl>
|
{stats.isLoading ? (
|
||||||
</section>
|
<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">
|
||||||
<section className="mt-6 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-6">
|
<Receipt className="w-8 h-8 text-slate-300" />
|
||||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 mb-2">Что дальше</h2>
|
<div>Чеков пока нет.</div>
|
||||||
<ul className="text-sm text-slate-600 dark:text-slate-300 space-y-1.5 list-disc list-inside">
|
<div className="text-xs">График появится когда появятся первые продажи.</div>
|
||||||
<li>Phase 2: приёмка товара, розничные продажи, складские остатки</li>
|
</div>
|
||||||
<li>Phase 3: инвентаризация, списание, оприходование, возвраты поставщикам</li>
|
) : (
|
||||||
<li>Phase 4: ценники, отчёты P&L, бонусная система, аудит</li>
|
<SalesChart series={stats.data!.series} currencyCode="₸" />
|
||||||
<li>Phase 5: Windows-касса + синхронизация + весы</li>
|
)}
|
||||||
</ul>
|
|
||||||
</section>
|
</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">
|
||||||
|
<MiniCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
|
||||||
|
<MiniCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
|
||||||
|
<MiniCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
|
||||||
|
<MiniCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue