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>
242 lines
12 KiB
TypeScript
242 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|