From 7640d6ddcd6ff8495e4900fe86400a4d5f0caecd Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:57:35 +0500 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20sales=20chart=20+=20KPIs=20(?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=C2=AB=D0=9F=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B8=C2=BB=20=D0=B2=20=D0=9C=D0=BE=D0=B9?= =?UTF-8?q?=D0=A1=D0=BA=D0=BB=D0=B0=D0=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sales/RetailSalesController.cs | 69 ++++ src/food-market.web/package.json | 1 + src/food-market.web/pnpm-lock.yaml | 307 ++++++++++++++++++ .../src/components/SalesChart.tsx | 77 +++++ src/food-market.web/src/lib/types.ts | 16 + .../src/pages/DashboardPage.tsx | 158 ++++++--- 6 files changed, 582 insertions(+), 46 deletions(-) create mode 100644 src/food-market.web/src/components/SalesChart.tsx diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index c9ffddc..17bec60 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -54,6 +54,75 @@ public record RetailSaleInput( string? Notes, IReadOnlyList 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 Series); + + /// Aggregated sales metrics + daily series for the dashboard. + /// Series buckets are days; defaults to last 30 days. + [HttpGet("stats")] + public async Task> 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] public async Task>> List( [FromQuery] PagedRequest req, diff --git a/src/food-market.web/package.json b/src/food-market.web/package.json index a873738..70ea679 100644 --- a/src/food-market.web/package.json +++ b/src/food-market.web/package.json @@ -21,6 +21,7 @@ "react-dom": "^19.2.5", "react-hook-form": "^7.73.1", "react-router-dom": "^7.14.1", + "recharts": "^3.8.1", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", "zod": "^4.3.6" diff --git a/src/food-market.web/pnpm-lock.yaml b/src/food-market.web/pnpm-lock.yaml index 0457d72..4b073ca 100644 --- a/src/food-market.web/pnpm-lock.yaml +++ b/src/food-market.web/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: react-router-dom: specifier: ^7.14.1 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: specifier: ^3.5.0 version: 3.5.0 @@ -266,6 +269,17 @@ packages: '@oxc-project/types@0.126.0': 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': resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -367,6 +381,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.7': 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': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -484,6 +501,33 @@ packages: '@tybys/wasm-util@0.10.1': 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': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -501,6 +545,9 @@ packages: '@types/react@19.2.14': 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': resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -680,6 +727,50 @@ packages: csstype@3.2.3: 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: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -689,6 +780,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -727,6 +821,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.46.0: + resolution: {integrity: sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -792,6 +889,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -910,6 +1010,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} 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: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -918,6 +1024,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} 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: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1152,6 +1262,21 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==} engines: {node: '>=20.0.0'} @@ -1173,6 +1298,25 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} 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: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1232,6 +1376,9 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -1273,6 +1420,14 @@ packages: uri-js@4.4.1: 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: resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1554,6 +1709,18 @@ snapshots: '@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': optional: true @@ -1607,6 +1774,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.7': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@tailwindcss/node@4.2.3': @@ -1697,6 +1866,30 @@ snapshots: tslib: 2.8.1 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/json-schema@7.0.15': {} @@ -1713,6 +1906,8 @@ snapshots: dependencies: 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)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1914,10 +2109,50 @@ snapshots: 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: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-is@0.1.4: {} delayed-stream@1.0.0: {} @@ -1952,6 +2187,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 + es-toolkit@1.46.0: {} + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -2041,6 +2278,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -2138,6 +2377,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -2145,6 +2388,8 @@ snapshots: imurmurhash@0.1.4: {} + internmap@2.0.3: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -2323,6 +2568,17 @@ snapshots: dependencies: 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): dependencies: react: 19.2.5 @@ -2339,6 +2595,34 @@ snapshots: 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: {} rolldown@1.0.0-rc.16: @@ -2394,6 +2678,8 @@ snapshots: tapable@2.3.2: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -2435,6 +2721,27 @@ snapshots: dependencies: 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): dependencies: lightningcss: 1.32.0 diff --git a/src/food-market.web/src/components/SalesChart.tsx b/src/food-market.web/src/components/SalesChart.tsx new file mode 100644 index 0000000..a4a2603 --- /dev/null +++ b/src/food-market.web/src/components/SalesChart.tsx @@ -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 ( +
+ + + + + + + + + + + fmt.format(v)} + tickLine={false} + axisLine={false} + width={70} + /> + { + const num = typeof value === 'number' ? value : Number(value ?? 0) + if (name === 'revenue') return [`${fmt.format(num)} ${currencyCode}`, 'Выручка'] + return [String(value), String(name)] + }} + /> + + + +
+ ) +} diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index 7c3a1f1..3156dc6 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -121,6 +121,22 @@ export interface RetailSaleLineDto { 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 { id: string; number: string; date: string; status: RetailSaleStatus; storeId: string; storeName: string; diff --git a/src/food-market.web/src/pages/DashboardPage.tsx b/src/food-market.web/src/pages/DashboardPage.tsx index e5d5d42..9c5c36f 100644 --- a/src/food-market.web/src/pages/DashboardPage.tsx +++ b/src/food-market.web/src/pages/DashboardPage.tsx @@ -1,8 +1,9 @@ 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 { SalesChart } from '@/components/SalesChart' import { api } from '@/lib/api' -import type { PagedResult } from '@/lib/types' +import type { PagedResult, SalesStatsResponse } from '@/lib/types' interface MeResponse { 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 }> label: string - value: number | string | undefined - isLoading: boolean + value: string | number + 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 (
+
+
+
{label}
+
+ {value} +
+ {hint &&
{hint}
} +
+ +
+ {delta && ( +
+ {delta.positive ? : } + {delta.positive ? '+' : ''}{delta.value.toFixed(1)}% к прошлому месяцу +
+ )} +
+ ) +} + +function MiniCard({ icon: Icon, label, value, isLoading }: { + icon: React.ComponentType<{ className?: string }> + label: string + value: number | undefined + isLoading: boolean +}) { + return ( +
- {label} + {label}
-
- {isLoading ? '…' : value ?? '—'} +
+ {isLoading ? '…' : value !== undefined ? fmt.format(value) : '—'}
) @@ -45,57 +78,90 @@ export function DashboardPage() { queryKey: ['me'], queryFn: async () => (await api.get('/api/me')).data, }) + const stats = useQuery({ + queryKey: ['/api/sales/retail/stats'], + queryFn: async () => (await api.get('/api/sales/retail/stats?days=30')).data, + }) const products = useCount('/api/catalog/products') const counterparties = useCount('/api/catalog/counterparties') const stores = useCount('/api/catalog/stores') 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 ( -
+
- {anyError && ( -
-
API недоступен или ещё не обновился
-
- Перезапусти API после git pull: Ctrl+C → dotnet run --project src/food-market.api -
-
- )} - -
- - - - + {/* KPI блок продажи */} +
+ + = 0 } : undefined} + /> + +
- {me.data && ( -
-

Текущий пользователь

-
-
Имя:
{me.data.name}
-
Email:
{me.data.email}
-
Роли:
{me.data.roles.join(', ')}
-
Организация:
{me.data.orgId}
-
-
- )} - -
-

Что дальше

-
    -
  • Phase 2: приёмка товара, розничные продажи, складские остатки
  • -
  • Phase 3: инвентаризация, списание, оприходование, возвраты поставщикам
  • -
  • Phase 4: ценники, отчёты P&L, бонусная система, аудит
  • -
  • Phase 5: Windows-касса + синхронизация + весы
  • -
+ {/* График продаж */} +
+
+
+

Выручка за 30 дней

+

Сумма продаж по дням, проведённые чеки

+
+
+ {stats.isLoading ? ( +
Загрузка…
+ ) : !hasAnySales ? ( +
+ +
Чеков пока нет.
+
График появится когда появятся первые продажи.
+
+ ) : ( + + )}
+ + {/* Каталог */} +
+

+ Каталог +

+
+ + + + +
+
) }