food-market/src/food-market.web/src/pages/DashboardPage.tsx
nns e13dd6937f perf(s14): индексы + N+1 fix + bundle -50% + WebP variants + pool + Hangfire timing
Sprint 14 — производительность с реальными замерами до/после.

Ключевые цифры:
- Sales-report SQL: 9.53ms → 7.09ms mean (-25%) после N+1 fix +
  индексов.
- Initial JS bundle: 1456 KB → 706 KB raw (-51%); gzip 389 KB →
  196 KB (-50%) через React.lazy на 30 редких страниц + Recharts.
- Lighthouse /login: Perf 89, A11y 92, BP 100 (target ≥85/90/90 ✓).

Подробности по каждому пункту + методология замеров — в
docs/sprint14-progress.md.

Что сделано:
1. Phase14a_PerfIndexes — composite (Org,Status,Date), partial
   (WHERE Status=1 AND NOT IsReturn) + INCLUDE, и composite
   stock_movements(Org,OccurredAt).
2. SalesReportController.FetchAsync — раньше каждая строка
   результата делала CASE WHEN ELSE (SELECT ... LIMIT 1)
   correlated subquery на RetailPoint.Name и User.FullName.
   Заменено на 2 IN-batch'a + dictionary lookup в C#.
3. App.tsx React.lazy для отчётов, audit-log, loyalty, super-admin,
   settings, all rare edit pages. Recharts вынесен в lazy chunk
   Dashboard'а (KPI рендерятся сразу).
4. SixLabors.ImageSharp v3.1.6 + ImageVariantService — генерирует
   thumb 256/medium 800 WebP@80 при загрузке. UploadsController
   ?size=thumb|medium с fallback. React <ProductImage> — <picture>
   + srcset.
5. ApplyDefaultPoolConfig на старте: Max=100, Min=10 (грей пул),
   Idle=300, Max Auto Prepare=20.
6. Lighthouse на /login /forgot-password /reset-password —
   все три проходят пороги.
7. JobTimingFilter + HangfireGlobalFilterRegistrar — каждый
   recurring job логирует длительность; >30s = Warning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 13:21:39 +05:00

242 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery, useQueryClient } from '@tanstack/react-query'
import { lazy, Suspense, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff, CalendarDays } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { Skeleton } from '@/components/Skeleton'
import { api } from '@/lib/api'
import { toast } from '@/lib/toast'
import { useNotificationsHub } from '@/lib/useNotificationsHub'
import type { PagedResult, SalesStatsResponse } from '@/lib/types'
// Sprint 14: SalesChart тянет recharts (~150КБ raw / ~50КБ gzip). Лениво —
// сам Dashboard рендерится сразу с KPI'ами, чарт догружается за ~50мс.
const SalesChart = lazy(() => import('@/components/SalesChart').then(m => ({ default: m.SalesChart })))
// Виджеты lazy: они тянут heavy-ish DOM (списки), но критично только KPI/график
// для first-paint. Чанки уйдут отдельным запросом, skeleton — мгновенно.
const TopProductsWidget = lazy(() => import('@/components/DashboardWidgets').then(m => ({ default: m.TopProductsWidget })))
const LowStockWidget = lazy(() => import('@/components/DashboardWidgets').then(m => ({ default: m.LowStockWidget })))
const RecentSalesWidget = lazy(() => import('@/components/DashboardWidgets').then(m => ({ default: m.RecentSalesWidget })))
const MarginWidget = lazy(() => import('@/components/DashboardWidgets').then(m => ({ default: m.MarginWidget })))
interface MeResponse {
sub: string
name: string
email: string
roles: string[]
orgId: string
}
function useCount(url: string) {
return useQuery({
queryKey: [url, 'count'],
queryFn: async () => (await api.get<PagedResult<unknown>>(`${url}?pageSize=1`)).data.total,
})
}
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: string | number
hint?: string
delta?: { value: number; positive: boolean; suffix?: string }
}
function KpiCard({ icon: Icon, label, value, hint, delta }: KpiCardProps) {
return (
<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 dark:text-slate-400">{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)}{delta.suffix ?? '% к прошлому месяцу'}
</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">
<span className="text-xs text-slate-500 dark:text-slate-400">{label}</span>
<Icon className="w-4 h-4 text-slate-400" />
</div>
<div className="text-xl font-semibold mt-1.5 text-slate-900 dark:text-slate-100">
{isLoading ? '…' : value !== undefined ? fmt.format(value) : '—'}
</div>
</div>
)
}
export function DashboardPage() {
const qc = useQueryClient()
const { t } = useTranslation()
const me = useQuery({
queryKey: ['me'],
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 counterparties = useCount('/api/catalog/counterparties')
const stores = useCount('/api/catalog/stores')
const retailPoints = useCount('/api/catalog/retail-points')
// Live-обновление через SignalR. SalePosted сразу инвалидирует stats query
// (виджет «Выручка сегодня» подтянет свежее значение). LowStock — bell-toast
// с именем продукта. liveRevenue — оптимистичное приращение к UI до того,
// как stats refetch'нётся (на гладкость глаза).
const [liveRevenueDelta, setLiveRevenueDelta] = useState(0)
const [liveCountDelta, setLiveCountDelta] = useState(0)
const { isConnected } = useNotificationsHub({
onSalePosted: (p) => {
setLiveRevenueDelta((x) => x + p.total)
setLiveCountDelta((x) => x + 1)
qc.invalidateQueries({ queryKey: ['/api/sales/retail/stats'] })
// Виджеты «Топ товаров», «Последние продажи», «Маржа» зависят от продаж —
// инвалидируем все четыре дашборд-запроса (low-stock тоже, т.к. продажа
// могла столкнуть остаток ниже минимума).
qc.invalidateQueries({ queryKey: ['/api/dashboard/top-products'] })
qc.invalidateQueries({ queryKey: ['/api/dashboard/low-stock'] })
qc.invalidateQueries({ queryKey: ['/api/dashboard/recent-sales'] })
qc.invalidateQueries({ queryKey: ['/api/dashboard/margin'] })
},
onSupplyPosted: (p) => {
toast.info(`Приёмка ${p.number} проведена на ${fmtMoney(p.total)}`, { title: 'Приёмка', duration: 4000 })
},
onLowStock: (p) => {
const store = p.storeName ? ` на «${p.storeName}»` : ''
toast.error(
`«${p.productName}»${store}: остаток ${p.quantity} ≤ минимум ${p.minStock}`,
{ title: 'Низкий остаток', duration: 8000 },
)
},
})
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 (
<div className="p-4 sm:p-6 space-y-5 sm:space-y-6 overflow-auto">
<PageHeader
title={t('dashboard.title')}
description={me.data ? t('dashboard.welcome', { name: me.data.name }) : t('dashboard.fallbackDescription')}
actions={(
<span title={isConnected ? t('dashboard.liveOn') : t('dashboard.liveOff')} className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
{isConnected
? <Wifi className="w-3.5 h-3.5 text-emerald-500" />
: <WifiOff className="w-3.5 h-3.5 text-slate-400" />}
{isConnected ? t('common.live') : t('common.offline')}
</span>
)}
/>
{/* KPI блок продажи: today / week / month + prev-month сравнение */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
icon={Banknote}
label={t('dashboard.revenueToday')}
value={stats.isLoading ? '…' : `${fmtMoney((stats.data?.revenueToday ?? 0) + liveRevenueDelta)}`}
hint={t('dashboard.receiptsCount', { count: (stats.data?.transactionsToday ?? 0) + liveCountDelta })}
/>
<KpiCard
icon={CalendarDays}
label={t('dashboard.revenueWeek', { defaultValue: 'Выручка за неделю' })}
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisWeek ?? 0)}`}
hint={t('dashboard.receiptsCount', { count: stats.data?.transactionsThisWeek ?? 0 })}
/>
<KpiCard
icon={Calendar}
label={t('dashboard.revenueMonth')}
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.revenueThisMonth ?? 0)}`}
hint={t('dashboard.receiptsCount', { count: stats.data?.transactionsThisMonth ?? 0 })}
delta={monthDelta !== null ? { value: monthDelta, positive: monthDelta >= 0, suffix: t('dashboard.deltaSuffix') } : undefined}
/>
<KpiCard
icon={Receipt}
label={t('dashboard.avgTicket')}
value={stats.isLoading ? '…' : `${fmtMoney(stats.data?.avgTicketThisMonth ?? 0)}`}
hint={t('dashboard.perMonth')}
/>
</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">{t('dashboard.chartTitle')}</h2>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{t('dashboard.chartSubtitle')}</p>
</div>
</div>
{stats.isLoading ? (
<Skeleton variant="block" className="h-72 w-full" />
) : !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>{t('dashboard.noSales')}</div>
<div className="text-xs">{t('dashboard.noSalesHint')}</div>
</div>
) : (
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<SalesChart series={stats.data!.series} currencyCode="₸" />
</Suspense>
)}
</section>
{/* Виджеты «Топ товаров», «Маржа», «Last sales», «Low stock» */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<TopProductsWidget days={7} />
</Suspense>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<MarginWidget days={30} />
</Suspense>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<RecentSalesWidget limit={10} />
</Suspense>
<Suspense fallback={<Skeleton variant="block" className="h-72 w-full" />}>
<LowStockWidget limit={10} />
</Suspense>
</div>
{/* Каталог */}
<div>
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3 uppercase tracking-wide">
{t('dashboard.catalogSection')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MiniCard icon={Package} label={t('dashboard.productsCount')} value={products.data} isLoading={products.isLoading} />
<MiniCard icon={Users} label={t('dashboard.counterpartiesCount')} value={counterparties.data} isLoading={counterparties.isLoading} />
<MiniCard icon={Warehouse} label={t('dashboard.storesCount')} value={stores.data} isLoading={stores.isLoading} />
<MiniCard icon={Store} label={t('dashboard.retailPointsCount')} value={retailPoints.data} isLoading={retailPoints.isLoading} />
</div>
</div>
</div>
)
}