docs: создать правила для синхронизации данных, layout и статистических компонентов
- DATA_SYNCHRONIZATION_RULES.md - правила синхронизации между компонентами - GRAPHQL_CACHE_RULES.md - настройки кеширования и fetchPolicy - CSS_LAYOUT_SCROLL_RULES.md - решение проблем с overflow и scroll - STATISTICAL_COMPONENTS_RULES.md - правила Master-Detail архитектуры Документация основана на исправлениях в кабинете фулфилмента 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
598
docs/presentation-layer/STATISTICAL_COMPONENTS_RULES.md
Normal file
598
docs/presentation-layer/STATISTICAL_COMPONENTS_RULES.md
Normal file
@ -0,0 +1,598 @@
|
||||
# 📊 ПРАВИЛА СТАТИСТИЧЕСКИХ КОМПОНЕНТОВ
|
||||
|
||||
> **Цель:** Обеспечить корректную работу, синхронизацию и консистентность статистических компонентов в системе SFERA
|
||||
|
||||
## 📋 **ОСНОВНЫЕ ПРИНЦИПЫ СТАТИСТИЧЕСКИХ КОМПОНЕНТОВ**
|
||||
|
||||
### 1. **АРХИТЕКТУРА MASTER-DETAIL**
|
||||
|
||||
```typescript
|
||||
// ✅ ПРАВИЛЬНАЯ архитектура связанных статистических компонентов
|
||||
interface StatisticsHierarchy {
|
||||
masterComponent: {
|
||||
location: '/fulfillment-warehouse'
|
||||
cards: ['РАСХОДНИКИ ФУЛФИЛМЕНТА', 'ТОВАРЫ СЕЛЛЕРОВ', ...]
|
||||
role: 'Обзор всего склада'
|
||||
}
|
||||
|
||||
detailComponent: {
|
||||
location: '/fulfillment-warehouse/supplies'
|
||||
cards: ['ВСЕГО ПОЗИЦИЙ', 'ДОСТУПНО', 'ОСТАТОК', 'НЕТ В НАЛИЧИИ', ...]
|
||||
role: 'Детализация расходников'
|
||||
}
|
||||
|
||||
// ОБЯЗАТЕЛЬНО: masterComponent.cards[X] === sum(detailComponent.items)
|
||||
consistency: 'Значения master должны равняться агрегации detail'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **ЕДИНЫЕ ИСТОЧНИКИ ДАННЫХ**
|
||||
|
||||
```typescript
|
||||
// ✅ ПРАВИЛЬНО - один источник данных для связанных статистик
|
||||
const warehouseQuery = useQuery(GET_WAREHOUSE_STATS, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: 30000,
|
||||
})
|
||||
|
||||
const suppliesQuery = useQuery(GET_SUPPLIES_STATS, {
|
||||
fetchPolicy: 'cache-and-network', // ← ОБЯЗАТЕЛЬНО то же самое!
|
||||
pollInterval: 30000, // ← ОБЯЗАТЕЛЬНО то же самое!
|
||||
})
|
||||
|
||||
// ОБЯЗАТЕЛЬНАЯ проверка консистентности
|
||||
useEffect(() => {
|
||||
const masterValue = warehouseQuery.data?.fulfillmentSupplies?.current
|
||||
const detailValue = suppliesQuery.data?.supplies?.reduce((sum, s) => sum + s.currentStock, 0)
|
||||
|
||||
if (masterValue !== detailValue) {
|
||||
console.error('🚨 STATISTICS SYNC ERROR:', { masterValue, detailValue })
|
||||
}
|
||||
}, [warehouseQuery.data, suppliesQuery.data])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **ТИПЫ СТАТИСТИЧЕСКИХ КАРТОЧЕК**
|
||||
|
||||
### **ТИП 1: Счётчики (Counters)**
|
||||
|
||||
```jsx
|
||||
// Подсчёт количества элементов
|
||||
;<StatCard title="Всего позиций" value={supplies.length} icon={Package} color="blue" format="number" />
|
||||
|
||||
// Логика расчёта
|
||||
const totalItems = supplies.length
|
||||
const availableItems = supplies.filter((s) => s.currentStock > 0).length
|
||||
const outOfStockItems = supplies.filter((s) => s.currentStock === 0).length
|
||||
```
|
||||
|
||||
### **ТИП 2: Агрегаторы (Aggregators)**
|
||||
|
||||
```jsx
|
||||
// Суммирование значений
|
||||
;<StatCard
|
||||
title="Остаток"
|
||||
value={supplies.reduce((sum, s) => sum + s.currentStock, 0)}
|
||||
unit="шт"
|
||||
icon={Package}
|
||||
color="blue"
|
||||
format="number"
|
||||
/>
|
||||
|
||||
// Логика расчёта
|
||||
const totalStock = supplies.reduce((sum, supply) => sum + supply.currentStock, 0)
|
||||
const totalValue = supplies.reduce((sum, supply) => sum + supply.price * supply.currentStock, 0)
|
||||
```
|
||||
|
||||
### **ТИП 3: Индикаторы состояния (Status Indicators)**
|
||||
|
||||
```jsx
|
||||
// Показатели состояния системы
|
||||
<StatCard
|
||||
title="Доступно"
|
||||
value={availableSupplies.length}
|
||||
icon={CheckCircle}
|
||||
color="green"
|
||||
subtitle={`из ${totalSupplies} позиций`}
|
||||
trend={{ value: +5, period: 'за неделю' }}
|
||||
/>
|
||||
```
|
||||
|
||||
### **ТИП 4: Финансовые показатели (Financial)**
|
||||
|
||||
```jsx
|
||||
// Денежные значения
|
||||
<StatCard
|
||||
title="Общая стоимость"
|
||||
value={totalInventoryValue}
|
||||
icon={DollarSign}
|
||||
color="purple"
|
||||
format="currency"
|
||||
currency="RUB"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏢 **СПЕЦИФИЧЕСКИЕ ПРАВИЛА ДЛЯ ФУЛФИЛМЕНТА**
|
||||
|
||||
### **КАРТОЧКИ ГЛАВНОГО DASHBOARD (/fulfillment-warehouse)**
|
||||
|
||||
```typescript
|
||||
interface WarehouseMasterStats {
|
||||
'РАСХОДНИКИ ФУЛФИЛМЕНТА': {
|
||||
source: 'fulfillmentConsumableInventory'
|
||||
calculation: 'SUM(currentStock)'
|
||||
syncWith: '/fulfillment-warehouse/supplies - карточка ОСТАТОК'
|
||||
}
|
||||
|
||||
'ТОВАРЫ СЕЛЛЕРОВ': {
|
||||
source: 'sellerInventoryOnWarehouse'
|
||||
calculation: 'SUM(currentStock)'
|
||||
syncWith: 'Таблица товаров селлеров'
|
||||
}
|
||||
|
||||
'АКТИВНЫЕ ЗАКАЗЫ': {
|
||||
source: 'supplyOrders'
|
||||
calculation: 'COUNT where status IN [PENDING, SHIPPED]'
|
||||
syncWith: 'Раздел входящих заказов'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **КАРТОЧКИ ПОДРАЗДЕЛА РАСХОДНИКОВ (/fulfillment-warehouse/supplies)**
|
||||
|
||||
```typescript
|
||||
interface SuppliesDetailStats {
|
||||
'ВСЕГО ПОЗИЦИЙ': {
|
||||
calculation: 'COUNT(DISTINCT productId)'
|
||||
description: 'Количество уникальных товаров'
|
||||
}
|
||||
|
||||
ДОСТУПНО: {
|
||||
calculation: 'COUNT WHERE currentStock > 0'
|
||||
description: 'Товары в наличии'
|
||||
}
|
||||
|
||||
ОСТАТОК: {
|
||||
calculation: 'SUM(currentStock)'
|
||||
description: 'Общее количество единиц товара'
|
||||
syncWith: 'Master: РАСХОДНИКИ ФУЛФИЛМЕНТА' // ← КРИТИЧЕСКАЯ СИНХРОНИЗАЦИЯ
|
||||
}
|
||||
|
||||
'НЕТ В НАЛИЧИИ': {
|
||||
calculation: 'COUNT WHERE currentStock = 0'
|
||||
description: 'Товары с нулевыми остатками'
|
||||
}
|
||||
|
||||
'ОБЩАЯ СТОИМОСТЬ': {
|
||||
calculation: 'SUM(price * currentStock)'
|
||||
format: 'currency'
|
||||
}
|
||||
|
||||
'В ПУТИ': {
|
||||
calculation: 'COUNT WHERE status = "shipped"'
|
||||
description: 'Товары в транспортировке'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **ПРАВИЛА СИНХРОНИЗАЦИИ СТАТИСТИК**
|
||||
|
||||
### **1. ОБЯЗАТЕЛЬНАЯ КОНСИСТЕНТНОСТЬ MASTER-DETAIL**
|
||||
|
||||
```typescript
|
||||
// ✅ ПРАВИЛЬНАЯ реализация проверки синхронизации
|
||||
const validateStatisticsConsistency = (masterData: any, detailData: any[]) => {
|
||||
const masterValue = masterData?.fulfillmentSupplies?.current || 0
|
||||
const detailValue = detailData.reduce((sum, item) => sum + item.currentStock, 0)
|
||||
|
||||
const tolerance = 0 // Для inventory данных - точное соответствие!
|
||||
const isConsistent = Math.abs(masterValue - detailValue) <= tolerance
|
||||
|
||||
if (!isConsistent) {
|
||||
console.error('🚨 CRITICAL STATISTICS INCONSISTENCY:', {
|
||||
component: 'FulfillmentStatistics',
|
||||
masterValue,
|
||||
detailValue,
|
||||
difference: masterValue - detailValue,
|
||||
timestamp: new Date().toISOString(),
|
||||
requiresAttention: true,
|
||||
})
|
||||
|
||||
// Уведомление пользователя о проблеме
|
||||
toast.error('Обнаружена несогласованность данных статистики')
|
||||
|
||||
// Попытка принудительного обновления
|
||||
refetchMasterData()
|
||||
refetchDetailData()
|
||||
}
|
||||
|
||||
return isConsistent
|
||||
}
|
||||
|
||||
// Использование в компонентах
|
||||
useEffect(() => {
|
||||
validateStatisticsConsistency(warehouseMasterData, suppliesDetailData)
|
||||
}, [warehouseMasterData, suppliesDetailData])
|
||||
```
|
||||
|
||||
### **2. АВТОМАТИЧЕСКОЕ ОБНОВЛЕНИЕ СВЯЗАННЫХ СТАТИСТИК**
|
||||
|
||||
```typescript
|
||||
// При изменении данных - все связанные статистики обновляются
|
||||
const handleInventoryUpdate = async (inventoryChange: InventoryChange) => {
|
||||
// 1. Обновляем основные данные
|
||||
await updateInventoryRecord(inventoryChange)
|
||||
|
||||
// 2. Принудительное обновление всех связанных статистик
|
||||
await Promise.all([
|
||||
refetchWarehouseStats(), // Master dashboard
|
||||
refetchSuppliesStats(), // Detail dashboard
|
||||
refetchServiceStats(), // Services section
|
||||
])
|
||||
|
||||
// 3. Проверяем консистентность после обновления
|
||||
setTimeout(() => validateStatisticsConsistency(), 1000)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **ДИЗАЙН И UI ПРАВИЛА**
|
||||
|
||||
### **СТАНДАРТНАЯ СТРУКТУРА КАРТОЧКИ**
|
||||
|
||||
```jsx
|
||||
<Card className="glass-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{/* Основная информация */}
|
||||
<p className="text-xs font-medium text-white/60 uppercase tracking-wider">{title}</p>
|
||||
<p className="text-2xl font-bold text-{color}-300 mt-1">
|
||||
{formattedValue} {unit && <span className="text-sm">{unit}</span>}
|
||||
</p>
|
||||
|
||||
{/* Дополнительная информация */}
|
||||
{subtitle && <p className="text-xs text-white/40 mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
{/* Иконка */}
|
||||
<div className="p-2 bg-{color}-500/20 rounded-lg">
|
||||
<Icon className="h-5 w-5 text-{color}-300" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### **ЦВЕТОВЫЕ СХЕМЫ ПО ТИПАМ**
|
||||
|
||||
```typescript
|
||||
const StatCardColors = {
|
||||
// Позитивные показатели (наличие, доступность)
|
||||
positive: {
|
||||
background: 'bg-green-500/20',
|
||||
text: 'text-green-300',
|
||||
icon: 'text-green-300',
|
||||
},
|
||||
|
||||
// Нейтральные показатели (общие счётчики)
|
||||
neutral: {
|
||||
background: 'bg-blue-500/20',
|
||||
text: 'text-blue-300',
|
||||
icon: 'text-blue-300',
|
||||
},
|
||||
|
||||
// Внимание требующие (мало на складе)
|
||||
warning: {
|
||||
background: 'bg-yellow-500/20',
|
||||
text: 'text-yellow-300',
|
||||
icon: 'text-yellow-300',
|
||||
},
|
||||
|
||||
// Негативные показатели (отсутствие, проблемы)
|
||||
negative: {
|
||||
background: 'bg-red-500/20',
|
||||
text: 'text-red-300',
|
||||
icon: 'text-red-300',
|
||||
},
|
||||
|
||||
// Финансовые показатели
|
||||
financial: {
|
||||
background: 'bg-purple-500/20',
|
||||
text: 'text-purple-300',
|
||||
icon: 'text-purple-300',
|
||||
},
|
||||
|
||||
// Процессы и активность
|
||||
process: {
|
||||
background: 'bg-orange-500/20',
|
||||
text: 'text-orange-300',
|
||||
icon: 'text-orange-300',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 **ФОРМАТИРОВАНИЕ ДАННЫХ**
|
||||
|
||||
### **ЧИСЛОВЫЕ ФОРМАТЫ**
|
||||
|
||||
```typescript
|
||||
const formatStatValue = (value: number, type: StatType) => {
|
||||
switch (type) {
|
||||
case 'currency':
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(value)
|
||||
|
||||
case 'number':
|
||||
return new Intl.NumberFormat('ru-RU').format(value)
|
||||
|
||||
case 'percentage':
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 1,
|
||||
}).format(value / 100)
|
||||
|
||||
case 'compact':
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value)
|
||||
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Примеры использования
|
||||
formatStatValue(1500, 'number') // "1 500"
|
||||
formatStatValue(25000, 'currency') // "25 000 ₽"
|
||||
formatStatValue(1500000, 'compact') // "1,5 млн"
|
||||
```
|
||||
|
||||
### **ЕДИНИЦЫ ИЗМЕРЕНИЯ**
|
||||
|
||||
```typescript
|
||||
const StatUnits = {
|
||||
quantity: 'шт',
|
||||
weight: 'кг',
|
||||
volume: 'л',
|
||||
currency: '₽',
|
||||
percentage: '%',
|
||||
count: '', // без единицы для простых счётчиков
|
||||
days: 'дн.',
|
||||
hours: 'ч',
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 **АНТИ-ПАТТЕРНЫ И ТИПИЧНЫЕ ОШИБКИ**
|
||||
|
||||
### **❌ НИКОГДА НЕ ДЕЛАЙТЕ:**
|
||||
|
||||
#### **1. Разные источники данных для связанных статистик**
|
||||
|
||||
```typescript
|
||||
// ❌ ПЛОХО - создаёт несогласованность
|
||||
const masterStats = useQuery(GET_OLD_WAREHOUSE_DATA) // legacy таблица
|
||||
const detailStats = useQuery(GET_NEW_SUPPLIES_DATA) // V2 таблица
|
||||
|
||||
// Результат: разные значения в связанных карточках
|
||||
```
|
||||
|
||||
#### **2. Игнорирование ошибок в статистике**
|
||||
|
||||
```typescript
|
||||
// ❌ ПЛОХО - скрывает проблемы с данными
|
||||
const { data, error } = useQuery(STATS_QUERY, {
|
||||
errorPolicy: 'ignore', // Ошибки не видны!
|
||||
})
|
||||
|
||||
// ✅ ПРАВИЛЬНО
|
||||
const { data, error } = useQuery(STATS_QUERY, {
|
||||
errorPolicy: 'all', // Показываем ошибки
|
||||
onError: (error) => {
|
||||
console.error('Stats error:', error)
|
||||
toast.error('Ошибка загрузки статистики')
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### **3. Вычисления на фронтенде вместо БД**
|
||||
|
||||
```typescript
|
||||
// ❌ ПЛОХО - медленно и неточно
|
||||
const totalValue = supplies.map(s => s.price * s.quantity).reduce((a, b) => a + b, 0)
|
||||
|
||||
// ✅ ПРАВИЛЬНО - вычисления в GraphQL resolver
|
||||
query GetSuppliesStats {
|
||||
suppliesStats {
|
||||
totalValue # Рассчитано на сервере
|
||||
totalStock # Рассчитано на сервере
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **4. Отсутствие проверки синхронизации**
|
||||
|
||||
```typescript
|
||||
// ❌ ПЛОХО - не контролируем консистентность
|
||||
const Stats = () => {
|
||||
const master = useQuery(MASTER_QUERY)
|
||||
const detail = useQuery(DETAIL_QUERY)
|
||||
|
||||
return <div>{master.value} vs {detail.sum}</div> // Может не совпадать!
|
||||
}
|
||||
|
||||
// ✅ ПРАВИЛЬНО - с проверкой синхронизации
|
||||
const Stats = () => {
|
||||
const master = useQuery(MASTER_QUERY)
|
||||
const detail = useQuery(DETAIL_QUERY)
|
||||
|
||||
useEffect(() => {
|
||||
validateConsistency(master.data, detail.data)
|
||||
}, [master.data, detail.data])
|
||||
|
||||
return <ValidatedStatsView />
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **ИНСТРУМЕНТЫ ОТЛАДКИ И МОНИТОРИНГА**
|
||||
|
||||
### **DEBUG КОМПОНЕНТ ДЛЯ СТАТИСТИКИ**
|
||||
|
||||
```jsx
|
||||
const StatisticsDebugPanel = ({ masterData, detailData }) => {
|
||||
if (process.env.NODE_ENV !== 'development') return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-black/80 p-4 rounded-lg text-xs text-white">
|
||||
<h3>Statistics Debug</h3>
|
||||
<div>Master: {masterData?.value}</div>
|
||||
<div>Detail Sum: {detailData?.reduce((sum, item) => sum + item.value, 0)}</div>
|
||||
<div>Sync: {masterData?.value === detailSum ? '✅' : '❌'}</div>
|
||||
<div>Last Update: {new Date().toLocaleTimeString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### **АВТОМАТИЧЕСКИЕ ТЕСТЫ СИНХРОНИЗАЦИИ**
|
||||
|
||||
```typescript
|
||||
// Тест для проверки синхронизации статистик
|
||||
describe('Statistics Synchronization', () => {
|
||||
it('должен синхронизировать master и detail значения', async () => {
|
||||
const { masterValue } = await fetchMasterStats()
|
||||
const detailItems = await fetchDetailItems()
|
||||
const detailSum = detailItems.reduce((sum, item) => sum + item.value, 0)
|
||||
|
||||
expect(masterValue).toBe(detailSum)
|
||||
})
|
||||
|
||||
it('должен обновлять связанные статистики при изменении данных', async () => {
|
||||
const initialStats = await fetchStats()
|
||||
await updateInventory({ productId: '1', change: +10 })
|
||||
|
||||
// Ждём обновления
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
const updatedStats = await fetchStats()
|
||||
expect(updatedStats.totalStock).toBe(initialStats.totalStock + 10)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **ШАБЛОНЫ КОМПОНЕНТОВ**
|
||||
|
||||
### **БАЗОВЫЙ СТАТИСТИЧЕСКИЙ КОМПОНЕНТ**
|
||||
|
||||
```tsx
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: number | string
|
||||
icon: React.ComponentType
|
||||
color: 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'orange'
|
||||
unit?: string
|
||||
subtitle?: string
|
||||
trend?: { value: number; period: string }
|
||||
format?: 'number' | 'currency' | 'percentage' | 'compact'
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
unit,
|
||||
subtitle,
|
||||
trend,
|
||||
format = 'number',
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
const formattedValue = useMemo(() => {
|
||||
if (loading) return '...'
|
||||
if (error) return 'Ошибка'
|
||||
return formatStatValue(Number(value), format)
|
||||
}, [value, format, loading, error])
|
||||
|
||||
return (
|
||||
<Card className="glass-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-white/60 uppercase tracking-wider">{title}</p>
|
||||
<p className={`text-2xl font-bold text-${color}-300 mt-1`}>
|
||||
{formattedValue}
|
||||
{unit && <span className="text-sm ml-1">{unit}</span>}
|
||||
</p>
|
||||
{subtitle && <p className="text-xs text-white/40 mt-1">{subtitle}</p>}
|
||||
{trend && (
|
||||
<p className="text-xs text-white/50 mt-1">
|
||||
{trend.value > 0 ? '+' : ''}
|
||||
{trend.value} {trend.period}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-2 bg-${color}-500/20 rounded-lg`}>
|
||||
<Icon className={`h-5 w-5 text-${color}-300`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### **КОМПОНЕНТ ГРУППЫ СТАТИСТИК**
|
||||
|
||||
```tsx
|
||||
interface StatsGridProps {
|
||||
stats: Array<StatCardProps & { id: string }>
|
||||
columns?: 1 | 2 | 3 | 4 | 6
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, columns = 6, loading, error }) => {
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="glass-card p-4">
|
||||
<div className="text-red-300 text-center">Ошибка загрузки статистики: {error}</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
grid grid-cols-1
|
||||
md:grid-cols-2
|
||||
lg:grid-cols-${Math.min(columns, 4)}
|
||||
xl:grid-cols-${columns}
|
||||
gap-4
|
||||
`}
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
<StatCard key={stat.id} {...stat} loading={loading} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Следование этим правилам обеспечит надёжную работу статистических компонентов!** 🚀
|
Reference in New Issue
Block a user