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:
390
docs/api-layer/GRAPHQL_CACHE_RULES.md
Normal file
390
docs/api-layer/GRAPHQL_CACHE_RULES.md
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
# 🔄 ПРАВИЛА КЕШИРОВАНИЯ GRAPHQL И FETCHPOLICY
|
||||||
|
|
||||||
|
> **Цель:** Обеспечить корректное кеширование GraphQL данных и предотвратить проблемы синхронизации между компонентами
|
||||||
|
|
||||||
|
## 📋 **ОСНОВНЫЕ ПРИНЦИПЫ КЕШИРОВАНИЯ**
|
||||||
|
|
||||||
|
### 1. **СИНХРОНИЗАЦИЯ СВЯЗАННЫХ КОМПОНЕНТОВ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ ПРАВИЛЬНО - одинаковые настройки для связанных данных
|
||||||
|
const masterComponent = useQuery(GET_WAREHOUSE_STATS, {
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
pollInterval: 30000,
|
||||||
|
errorPolicy: 'all',
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailComponent = useQuery(GET_SUPPLIES_DETAILS, {
|
||||||
|
fetchPolicy: 'cache-and-network', // ← ОБЯЗАТЕЛЬНО то же самое!
|
||||||
|
pollInterval: 30000, // ← ОБЯЗАТЕЛЬНО то же самое!
|
||||||
|
errorPolicy: 'all', // ← ОБЯЗАТЕЛЬНО то же самое!
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ НЕПРАВИЛЬНО - разные настройки создают рассинхронизацию
|
||||||
|
const masterComponent = useQuery(GET_WAREHOUSE_STATS, {
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailComponent = useQuery(GET_SUPPLIES_DETAILS, {
|
||||||
|
// default fetchPolicy: 'cache-first' - СОЗДАЁТ ПРОБЛЕМУ!
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **ОБЯЗАТЕЛЬНЫЕ FETCH POLICIES ПО ТИПАМ КОМПОНЕНТОВ**
|
||||||
|
|
||||||
|
#### **Dashboard и Статистика**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useQuery(DASHBOARD_QUERY, {
|
||||||
|
fetchPolicy: 'cache-and-network', // Всегда актуальные данные
|
||||||
|
pollInterval: 30000, // Обновление каждые 30 сек
|
||||||
|
errorPolicy: 'all', // Показывать частичные данные при ошибках
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Связанные компоненты (Master-Detail)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Все компоненты одной функциональности ДОЛЖНЫ иметь идентичные настройки
|
||||||
|
const sharedQueryOptions = {
|
||||||
|
fetchPolicy: 'cache-and-network' as const,
|
||||||
|
pollInterval: 30000,
|
||||||
|
errorPolicy: 'all' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применение во всех связанных компонентах
|
||||||
|
useQuery(MASTER_QUERY, sharedQueryOptions)
|
||||||
|
useQuery(DETAIL_QUERY, sharedQueryOptions)
|
||||||
|
useQuery(STATS_QUERY, sharedQueryOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Списки и таблицы**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useQuery(LIST_QUERY, {
|
||||||
|
fetchPolicy: 'cache-and-network', // Актуальные данные
|
||||||
|
pollInterval: 60000, // 1 минута для списков
|
||||||
|
notifyOnNetworkStatusChange: true, // Показывать статус загрузки
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Редко изменяющиеся данные**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useQuery(STATIC_DATA_QUERY, {
|
||||||
|
fetchPolicy: 'cache-first', // Кеш приоритетен
|
||||||
|
pollInterval: 300000, // 5 минут
|
||||||
|
errorPolicy: 'ignore', // Не показывать ошибки для статичных данных
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏢 **СПЕЦИФИЧЕСКИЕ ПРАВИЛА ДЛЯ ФУЛФИЛМЕНТА**
|
||||||
|
|
||||||
|
### **ГРУППЫ СИНХРОНИЗИРОВАННЫХ КОМПОНЕНТОВ**
|
||||||
|
|
||||||
|
#### **Группа 1: Складская статистика**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Все эти компоненты ДОЛЖНЫ использовать одинаковые настройки:
|
||||||
|
const warehouseStatsOptions = {
|
||||||
|
fetchPolicy: 'cache-and-network' as const,
|
||||||
|
pollInterval: 30000,
|
||||||
|
errorPolicy: 'all' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Главный dashboard (/fulfillment-warehouse)
|
||||||
|
useQuery(GET_WAREHOUSE_STATS, warehouseStatsOptions)
|
||||||
|
|
||||||
|
// 2. Подраздел расходников (/fulfillment-warehouse/supplies)
|
||||||
|
useQuery(GET_SUPPLIES_STATS, warehouseStatsOptions)
|
||||||
|
|
||||||
|
// 3. Раздел услуг (/services) - вкладка "Расходники"
|
||||||
|
useQuery(GET_SERVICE_SUPPLIES, warehouseStatsOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Группа 2: История поставок**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const suppliesHistoryOptions = {
|
||||||
|
fetchPolicy: 'cache-and-network' as const,
|
||||||
|
pollInterval: 30000,
|
||||||
|
errorPolicy: 'all' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Основные поставки (главная таблица)
|
||||||
|
useQuery(GET_MY_FULFILLMENT_SUPPLIES, suppliesHistoryOptions)
|
||||||
|
|
||||||
|
// 2. Детали поставок (раскрывающиеся строки)
|
||||||
|
useQuery(GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES, suppliesHistoryOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 **КРИТИЧЕСКИЕ АНТИ-ПАТТЕРНЫ**
|
||||||
|
|
||||||
|
### **❌ НИКОГДА НЕ ДЕЛАЙТЕ:**
|
||||||
|
|
||||||
|
#### **1. Разные fetchPolicy для связанных данных**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ СОЗДАЁТ РАССИНХРОНИЗАЦИЮ
|
||||||
|
const warehouse = useQuery(GET_WAREHOUSE, { fetchPolicy: 'cache-and-network' })
|
||||||
|
const supplies = useQuery(GET_SUPPLIES, {}) // default cache-first
|
||||||
|
|
||||||
|
// Результат: warehouse показывает новые данные, supplies - старые
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **2. Разные pollInterval для одной функциональности**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ СОЗДАЁТ НЕСОГЛАСОВАННОСТЬ
|
||||||
|
const stats = useQuery(GET_STATS, { pollInterval: 30000 })
|
||||||
|
const details = useQuery(GET_DETAILS, { pollInterval: 60000 })
|
||||||
|
|
||||||
|
// Результат: stats обновляется чаще details - значения не совпадают
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **3. Смешивание cache-first и cache-and-network**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ КЛАССИЧЕСКАЯ ОШИБКА
|
||||||
|
const mainData = useQuery(MAIN_QUERY, { fetchPolicy: 'cache-and-network' })
|
||||||
|
const relatedData = useQuery(RELATED_QUERY, { fetchPolicy: 'cache-first' })
|
||||||
|
|
||||||
|
// Результат: mainData актуальные, relatedData устаревшие
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **4. Игнорирование ошибок в критических компонентах**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ СКРЫВАЕТ ПРОБЛЕМЫ
|
||||||
|
useQuery(CRITICAL_DATA_QUERY, {
|
||||||
|
errorPolicy: 'ignore', // Не показывает ошибки когда нужно!
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **ПРАВИЛА ПО ТИПАМ ДАННЫХ**
|
||||||
|
|
||||||
|
### **СТАТИСТИКА И DASHBOARD**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const dashboardOptions = {
|
||||||
|
fetchPolicy: 'cache-and-network', // Всегда актуальные данные
|
||||||
|
pollInterval: 30000, // 30 сек - критическая актуальность
|
||||||
|
errorPolicy: 'all', // Показывать частичные данные
|
||||||
|
notifyOnNetworkStatusChange: true, // Показывать индикатор обновления
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ИНВЕНТАРЬ И ОСТАТКИ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const inventoryOptions = {
|
||||||
|
fetchPolicy: 'cache-and-network', // Остатки должны быть точными
|
||||||
|
pollInterval: 30000, // Частое обновление
|
||||||
|
errorPolicy: 'all', // Критические данные
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ИСТОРИЯ И ЛОГИ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const historyOptions = {
|
||||||
|
fetchPolicy: 'cache-first', // История не меняется часто
|
||||||
|
pollInterval: 120000, // 2 минуты достаточно
|
||||||
|
errorPolicy: 'all', // Показывать что есть
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **СПРАВОЧНИКИ И КАТАЛОГИ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const catalogOptions = {
|
||||||
|
fetchPolicy: 'cache-first', // Справочники стабильны
|
||||||
|
pollInterval: 300000, // 5 минут
|
||||||
|
errorPolicy: 'ignore', // Не критично
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 **ПРАКТИЧЕСКИЕ ПРИМЕРЫ ИСПРАВЛЕНИЙ**
|
||||||
|
|
||||||
|
### **ПРИМЕР 1: Рассинхронизация карточек статистики**
|
||||||
|
|
||||||
|
**❌ Проблема:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Главный dashboard
|
||||||
|
const warehouseStats = useQuery(GET_WAREHOUSE_STATS, {
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Подраздел supplies
|
||||||
|
const suppliesStats = useQuery(GET_SUPPLIES_STATS, {
|
||||||
|
// default fetchPolicy: 'cache-first' - ПРОБЛЕМА!
|
||||||
|
})
|
||||||
|
|
||||||
|
// Результат: карточка "РАСХОДНИКИ ФУЛФИЛМЕНТА" = 130, карточка "ОСТАТОК" = 160
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Решение:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Общие настройки для всех статистических компонентов
|
||||||
|
const statsOptions = {
|
||||||
|
fetchPolicy: 'cache-and-network' as const,
|
||||||
|
pollInterval: 30000,
|
||||||
|
errorPolicy: 'all' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем везде одинаково
|
||||||
|
const warehouseStats = useQuery(GET_WAREHOUSE_STATS, statsOptions)
|
||||||
|
const suppliesStats = useQuery(GET_SUPPLIES_STATS, statsOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ПРИМЕР 2: История поставок не синхронизируется с основной таблицей**
|
||||||
|
|
||||||
|
**❌ Проблема:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Основная таблица
|
||||||
|
const supplies = useQuery(GET_SUPPLIES, { fetchPolicy: 'cache-and-network' })
|
||||||
|
|
||||||
|
// История в раскрывающихся строках
|
||||||
|
const history = useQuery(GET_SUPPLY_HISTORY, {}) // default cache-first
|
||||||
|
|
||||||
|
// Результат: основная таблица обновилась, история показывает старые данные
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Решение:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const syncedOptions = {
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
pollInterval: 30000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplies = useQuery(GET_SUPPLIES, syncedOptions)
|
||||||
|
const history = useQuery(GET_SUPPLY_HISTORY, syncedOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 **ЧЕКЛИСТ НАСТРОЙКИ КЕШИРОВАНИЯ**
|
||||||
|
|
||||||
|
### **Перед релизом компонента:**
|
||||||
|
|
||||||
|
- [ ] Определили группу связанных компонентов
|
||||||
|
- [ ] Выбрали единую fetchPolicy для группы
|
||||||
|
- [ ] Установили одинаковые pollInterval значения
|
||||||
|
- [ ] Настроили errorPolicy в соответствии с критичностью
|
||||||
|
- [ ] Протестировали синхронизацию данных
|
||||||
|
|
||||||
|
### **При обнаружении рассинхронизации:**
|
||||||
|
|
||||||
|
- [ ] Проверили fetchPolicy всех связанных запросов
|
||||||
|
- [ ] Сравнили pollInterval значения
|
||||||
|
- [ ] Убедились в использовании одних таблиц БД
|
||||||
|
- [ ] Проверили последовательность обновления данных
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 **МОНИТОРИНГ И ОТЛАДКА**
|
||||||
|
|
||||||
|
### **DEBUG ЛОГИ ДЛЯ КЕШИРОВАНИЯ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Добавляйте в компоненты для контроля кеширования
|
||||||
|
useQuery(QUERY, {
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
onCompleted: (data) => {
|
||||||
|
console.log('CACHE DEBUG:', {
|
||||||
|
query: 'QUERY_NAME',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
dataLength: data?.items?.length,
|
||||||
|
source: 'network/cache',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('CACHE ERROR:', {
|
||||||
|
query: 'QUERY_NAME',
|
||||||
|
error: error.message,
|
||||||
|
networkError: error.networkError?.message,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ПРОВЕРКА СИНХРОНИЗАЦИИ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Контрольные точки для проверки консистентности
|
||||||
|
useEffect(() => {
|
||||||
|
if (masterData && detailData) {
|
||||||
|
const masterValue = masterData.total
|
||||||
|
const detailValue = detailData.reduce((sum, item) => sum + item.value, 0)
|
||||||
|
|
||||||
|
if (Math.abs(masterValue - detailValue) > 0) {
|
||||||
|
console.error('🚨 CACHE SYNC ERROR:', {
|
||||||
|
component: 'ComponentName',
|
||||||
|
master: masterValue,
|
||||||
|
detail: detailValue,
|
||||||
|
diff: masterValue - detailValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [masterData, detailData])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 **РЕКОМЕНДАЦИИ ПО ПРОИЗВОДИТЕЛЬНОСТИ**
|
||||||
|
|
||||||
|
### **ОПТИМИЗАЦИЯ POLLING**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Адаптивные интервалы в зависимости от активности пользователя
|
||||||
|
const useAdaptivePolling = () => {
|
||||||
|
const [isActive, setIsActive] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
setIsActive(!document.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
pollInterval: isActive ? 30000 : 120000, // Реже обновляем неактивные вкладки
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **УСЛОВНОЕ КЕШИРОВАНИЕ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Разные стратегии для разных сценариев
|
||||||
|
const getCachePolicy = (dataType: string) => {
|
||||||
|
switch (dataType) {
|
||||||
|
case 'critical-stats':
|
||||||
|
return { fetchPolicy: 'cache-and-network', pollInterval: 30000 }
|
||||||
|
case 'user-lists':
|
||||||
|
return { fetchPolicy: 'cache-and-network', pollInterval: 60000 }
|
||||||
|
case 'reference-data':
|
||||||
|
return { fetchPolicy: 'cache-first', pollInterval: 300000 }
|
||||||
|
default:
|
||||||
|
return { fetchPolicy: 'cache-first' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Следование этим правилам обеспечит стабильное кеширование и синхронизацию GraphQL данных!** 🚀
|
489
docs/presentation-layer/CSS_LAYOUT_SCROLL_RULES.md
Normal file
489
docs/presentation-layer/CSS_LAYOUT_SCROLL_RULES.md
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
# 🎨 ПРАВИЛА CSS LAYOUT И СКРОЛЛА
|
||||||
|
|
||||||
|
> **Цель:** Предотвратить проблемы с overflow, scroll и позиционированием в Next.js 15 + React 19 приложении
|
||||||
|
|
||||||
|
## 📋 **ОСНОВНЫЕ ПРИНЦИПЫ LAYOUT**
|
||||||
|
|
||||||
|
### 1. **БАЗОВАЯ АРХИТЕКТУРА LAYOUT**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ ПРАВИЛЬНАЯ структура для всех страниц приложения
|
||||||
|
<div className="h-screen flex overflow-hidden"> {/* Контейнер полной высоты */}
|
||||||
|
<Sidebar /> {/* Фиксированная боковая панель */}
|
||||||
|
<main className={`
|
||||||
|
flex-1 /* Занимает оставшееся место */
|
||||||
|
${getSidebarMargin()} /* Динамический отступ */
|
||||||
|
px-4 py-3 /* Внутренние отступы */
|
||||||
|
flex flex-col /* Вертикальная компоновка */
|
||||||
|
transition-all duration-300 /* Плавные анимации */
|
||||||
|
overflow-hidden /* Предотвращение двойного скролла */
|
||||||
|
`}>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6"> {/* ЕДИНСТВЕННАЯ зона скролла */}
|
||||||
|
{/* Весь контент здесь */}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **КРИТИЧЕСКИЕ ПРАВИЛА OVERFLOW**
|
||||||
|
|
||||||
|
#### **✅ ПРАВИЛЬНО: Один уровень overflow**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Родительский контейнер */
|
||||||
|
.container {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden; /* Запрещаем скролл на уровне экрана */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Дочерний скроллируемый контейнер */
|
||||||
|
.scrollable-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto; /* ЕДИНСТВЕННАЯ зона скролла */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **❌ НЕПРАВИЛЬНО: Множественные overflow зоны**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Создаёт конфликты скролла */
|
||||||
|
.parent {
|
||||||
|
overflow-y: auto; /* Первый скролл */
|
||||||
|
}
|
||||||
|
.child {
|
||||||
|
overflow-y: auto; /* Второй скролл - ПРОБЛЕМА! */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 **ТИПИЧНЫЕ ПРОБЛЕМЫ И РЕШЕНИЯ**
|
||||||
|
|
||||||
|
### **ПРОБЛЕМА 1: Контент смещается вправо**
|
||||||
|
|
||||||
|
**❌ Причина:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ minHeight: '200vh' }}>
|
||||||
|
{' '}
|
||||||
|
{/* Принудительная высота */}
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{' '}
|
||||||
|
{/* Скрывает контент */}
|
||||||
|
{/* Контент не помещается и смещается */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Решение:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="space-y-6">
|
||||||
|
{' '}
|
||||||
|
{/* Простая вертикальная компоновка */}
|
||||||
|
{/* Контент автоматически размещается правильно */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ПРОБЛЕМА 2: Скролл не работает**
|
||||||
|
|
||||||
|
**❌ Причина:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="h-screen overflow-hidden">
|
||||||
|
{' '}
|
||||||
|
{/* Блокирует скролл */}
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
{' '}
|
||||||
|
{/* Дублирует блокировку */}
|
||||||
|
<div className="min-h-[200vh]">
|
||||||
|
{' '}
|
||||||
|
{/* Создаёт контент больше экрана */}
|
||||||
|
{/* Контент не скроллится */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Решение:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="h-screen flex flex-col overflow-hidden">
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{' '}
|
||||||
|
{/* ЕДИНСТВЕННАЯ зона скролла */}
|
||||||
|
{/* Контент свободно скроллится */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ПРОБЛЕМА 3: Двойной sidebar в layout**
|
||||||
|
|
||||||
|
**❌ Причина:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// В page.tsx
|
||||||
|
<div className="flex">
|
||||||
|
<Sidebar /> {/* Первый sidebar */}
|
||||||
|
<main>
|
||||||
|
<div className="flex">
|
||||||
|
<Sidebar /> {/* Второй sidebar - ДУБЛИРОВАНИЕ! */}
|
||||||
|
<content />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Решение:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// В page.tsx - УБРАТЬ дублирование
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar /> {/* ЕДИНСТВЕННЫЙ sidebar */}
|
||||||
|
<main className={`flex-1 ${getSidebarMargin()} overflow-hidden`}>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6">{/* Весь контент */}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ **АРХИТЕКТУРНЫЕ ПАТТЕРНЫ**
|
||||||
|
|
||||||
|
### **ПАТТЕРН 1: Dashboard с статистикой**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
export function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col overflow-hidden`}>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* Статистические карточки */}
|
||||||
|
<StatsCards />
|
||||||
|
|
||||||
|
{/* Основное содержимое */}
|
||||||
|
<MainContent />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ПАТТЕРН 2: Таблица с фильтрами**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
export function TablePage() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col overflow-hidden`}>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6">
|
||||||
|
{/* Фильтры (фиксированные) */}
|
||||||
|
<FiltersPanel />
|
||||||
|
|
||||||
|
{/* Таблица (скроллируемая) */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<DataTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ПАТТЕРН 3: Модальные окна**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ✅ Модалки не должны влиять на основной скролл
|
||||||
|
<Dialog>
|
||||||
|
<div className="max-h-[80vh] overflow-y-auto">
|
||||||
|
{' '}
|
||||||
|
{/* Скролл только внутри модалки */}
|
||||||
|
<ModalContent />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 **RESPONSIVE DESIGN ПРАВИЛА**
|
||||||
|
|
||||||
|
### **АДАПТИВНЫЕ КОНТЕЙНЕРЫ**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
grid
|
||||||
|
grid-cols-1 /* Мобильные: 1 колонка */
|
||||||
|
md:grid-cols-2 /* Планшеты: 2 колонки */
|
||||||
|
lg:grid-cols-4 /* Десктоп: 4 колонки */
|
||||||
|
xl:grid-cols-6 /* Большие экраны: 6 колонок */
|
||||||
|
gap-4
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Карточки адаптивно размещаются */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### **АДАПТИВНЫЕ ОТСТУПЫ**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<main className={`
|
||||||
|
flex-1
|
||||||
|
${getSidebarMargin()} /* Динамический отступ для sidebar */
|
||||||
|
px-4 py-3 /* Базовые отступы */
|
||||||
|
lg:px-6 lg:py-4 /* Увеличенные отступы на больших экранах */
|
||||||
|
overflow-hidden
|
||||||
|
`}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 **СПЕЦИФИЧЕСКИЕ ПРАВИЛА ДЛЯ SFERA**
|
||||||
|
|
||||||
|
### **ПРАВИЛА ДЛЯ ФУЛФИЛМЕНТ КОМПОНЕНТОВ**
|
||||||
|
|
||||||
|
#### **1. Главная страница склада**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// src/app/fulfillment-warehouse/page.tsx
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col overflow-hidden`}>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6">
|
||||||
|
<WarehouseHeader />
|
||||||
|
<WarehouseStats /> {/* Статистика - НЕ скроллится */}
|
||||||
|
<WarehouseContent /> {/* Контент - скроллится */}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **2. Таблицы поставок**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// src/components/fulfillment-warehouse/fulfillment-supplies-page.tsx
|
||||||
|
<div className="h-screen flex overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col overflow-hidden`}>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6">
|
||||||
|
{' '}
|
||||||
|
{/* ← КЛЮЧЕВОЙ ЭЛЕМЕНТ */}
|
||||||
|
<SuppliesHeader /> {/* Фильтры */}
|
||||||
|
<SuppliesStats /> {/* Статистика */}
|
||||||
|
<SuppliesList /> {/* Основная таблица - скроллируется естественно */}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **3. Раскрывающиеся детали (Master-Detail)**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Детали поставок в раскрывающихся строках
|
||||||
|
<tr className="border-t border-white/10">
|
||||||
|
<td colSpan="100%" className="p-0">
|
||||||
|
<div className="max-h-96 overflow-y-auto bg-white/5">
|
||||||
|
{' '}
|
||||||
|
{/* Локальный скролл */}
|
||||||
|
<DeliveryDetails />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ **ПРОИЗВОДИТЕЛЬНОСТЬ И ОПТИМИЗАЦИЯ**
|
||||||
|
|
||||||
|
### **ВИРТУАЛИЗАЦИЯ ДЛЯ БОЛЬШИХ СПИСКОВ**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Для таблиц с >100 строк
|
||||||
|
import { FixedSizeList as List } from 'react-window'
|
||||||
|
|
||||||
|
;<div className="h-96 overflow-hidden">
|
||||||
|
{' '}
|
||||||
|
{/* Контейнер фиксированной высоты */}
|
||||||
|
<List
|
||||||
|
height={384} // Высота контейнера
|
||||||
|
itemCount={items.length} // Количество элементов
|
||||||
|
itemSize={64} // Высота каждого элемента
|
||||||
|
className="scrollbar-thin" // Кастомный скроллбар
|
||||||
|
>
|
||||||
|
{({ index, style }) => (
|
||||||
|
<div style={style}>
|
||||||
|
<TableRow item={items[index]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### **LAZY LOADING ДЛЯ КОНТЕНТА**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const LazyTableSection = lazy(() => import('./TableSection'))
|
||||||
|
|
||||||
|
// В компоненте
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6">
|
||||||
|
<StatsSection /> {/* Загружается сразу */}
|
||||||
|
<Suspense fallback={<div>Загрузка...</div>}>
|
||||||
|
<LazyTableSection /> {/* Загружается по требованию */}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 **АНТИ-ПАТТЕРНЫ И ЗАПРЕТЫ**
|
||||||
|
|
||||||
|
### **❌ НИКОГДА НЕ ДЕЛАЙТЕ:**
|
||||||
|
|
||||||
|
#### **1. Принудительные размеры**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ❌ Создаёт проблемы с layout
|
||||||
|
<div style={{ minHeight: '200vh' }}>
|
||||||
|
<div className="h-[2000px]">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **2. Множественные overflow зоны**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ❌ Конфликты скролла
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **3. Смешивание fixed и sticky позиционирования**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ❌ Непредсказуемое поведение
|
||||||
|
<div className="fixed top-0">
|
||||||
|
<div className="sticky top-0">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **4. Игнорирование responsive дизайна**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ❌ Не адаптируется к мобильным устройствам
|
||||||
|
<div className="grid grid-cols-6 gap-4"> {/* Сломается на мобильных */}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 **ИНСТРУМЕНТЫ ОТЛАДКИ**
|
||||||
|
|
||||||
|
### **CSS DEBUG КЛАССЫ**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Добавьте для визуализации проблем */
|
||||||
|
.debug-borders * {
|
||||||
|
border: 1px solid red !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-overflow {
|
||||||
|
overflow: visible !important;
|
||||||
|
background: rgba(255, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-scroll {
|
||||||
|
scrollbar-color: red transparent !important;
|
||||||
|
scrollbar-width: thick !important;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **REACT DevTools**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Добавьте data-атрибуты для отладки
|
||||||
|
<div
|
||||||
|
className="overflow-y-auto"
|
||||||
|
data-scroll-zone="main-content"
|
||||||
|
data-debug="scroll-container"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
### **КОНСОЛЬНЫЕ ЛОГИ ДЛЯ РАЗМЕРОВ**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current
|
||||||
|
if (element) {
|
||||||
|
console.log('LAYOUT DEBUG:', {
|
||||||
|
scrollHeight: element.scrollHeight,
|
||||||
|
clientHeight: element.clientHeight,
|
||||||
|
offsetHeight: element.offsetHeight,
|
||||||
|
hasOverflow: element.scrollHeight > element.clientHeight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **ЧЕКЛИСТ ПРОВЕРКИ LAYOUT**
|
||||||
|
|
||||||
|
### **Перед релизом:**
|
||||||
|
|
||||||
|
- [ ] Единственная зона скролла на странице
|
||||||
|
- [ ] Нет принудительных высот (`minHeight: '200vh'`)
|
||||||
|
- [ ] Правильная структура с `h-screen` и `overflow-hidden`
|
||||||
|
- [ ] Responsive дизайн для мобильных устройств
|
||||||
|
- [ ] Sidebar не дублируется в компонентах
|
||||||
|
- [ ] Модальные окна не влияют на основной скролл
|
||||||
|
|
||||||
|
### **При проблемах со скроллом:**
|
||||||
|
|
||||||
|
- [ ] Проверить количество `overflow-y-auto` в иерархии
|
||||||
|
- [ ] Убедиться в отсутствии `overflow-hidden` на скроллируемом контейнере
|
||||||
|
- [ ] Проверить наличие `flex-1` у родительского контейнера
|
||||||
|
- [ ] Убрать принудительные размеры (`min-height`, `height: 200vh`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 **КАСТОМНЫЕ СКРОЛЛБАРЫ**
|
||||||
|
|
||||||
|
### **TAILWIND CLASSES**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="
|
||||||
|
overflow-y-auto
|
||||||
|
scrollbar-thin /* Тонкий скроллбар */
|
||||||
|
scrollbar-track-transparent
|
||||||
|
scrollbar-thumb-white/20
|
||||||
|
hover:scrollbar-thumb-white/30
|
||||||
|
">
|
||||||
|
```
|
||||||
|
|
||||||
|
### **CUSTOM CSS**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Кастомные скроллбары для SFERA */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Следование этим правилам обеспечит корректную работу layout и скролла во всех компонентах!** 🚀
|
286
docs/presentation-layer/DATA_SYNCHRONIZATION_RULES.md
Normal file
286
docs/presentation-layer/DATA_SYNCHRONIZATION_RULES.md
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
# 🔄 ПРАВИЛА СИНХРОНИЗАЦИИ ДАННЫХ МЕЖДУ КОМПОНЕНТАМИ
|
||||||
|
|
||||||
|
> **Цель:** Обеспечить консистентность данных между различными компонентами системы SFERA
|
||||||
|
|
||||||
|
## 📋 **ОСНОВНЫЕ ПРИНЦИПЫ**
|
||||||
|
|
||||||
|
### 1. **ЕДИНЫЙ ИСТОЧНИК ИСТИНЫ (Single Source of Truth)**
|
||||||
|
|
||||||
|
- ✅ **Один резолвер = одна таблица БД** для одного типа данных
|
||||||
|
- ✅ **V2 система** - приоритет над legacy кодом
|
||||||
|
- ❌ **Никогда не дублируйте** одни данные в разных резолверах
|
||||||
|
|
||||||
|
### 2. **КОНСИСТЕНТНАЯ CACHE POLICY**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ ПРАВИЛЬНО - одинаковая policy для связанных данных
|
||||||
|
const query1 = useQuery(QUERY_A, { fetchPolicy: 'cache-and-network' })
|
||||||
|
const query2 = useQuery(QUERY_B, { fetchPolicy: 'cache-and-network' })
|
||||||
|
|
||||||
|
// ❌ НЕПРАВИЛЬНО - разные policies создают рассинхронизацию
|
||||||
|
const query1 = useQuery(QUERY_A, { fetchPolicy: 'cache-and-network' })
|
||||||
|
const query2 = useQuery(QUERY_B, {}) // default cache-first policy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **ОБЯЗАТЕЛЬНЫЕ FETCH POLICIES**
|
||||||
|
|
||||||
|
#### **Для связанных компонентов:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
fetchPolicy: 'cache-and-network', // Всегда актуальные данные
|
||||||
|
pollInterval: 30000, // Автообновление каждые 30 сек
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Для dashboard/статистики:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
fetchPolicy: 'cache-and-network', // Синхронизация с основными данными
|
||||||
|
pollInterval: 30000, // Регулярное обновление
|
||||||
|
errorPolicy: 'all', // Показывать частичные данные при ошибках
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏢 **ПРАВИЛА ДЛЯ ФУЛФИЛМЕНТА**
|
||||||
|
|
||||||
|
### **ТАБЛИЦЫ V2 СИСТЕМЫ**
|
||||||
|
|
||||||
|
- **`fulfillmentConsumableInventory`** - складские остатки расходников
|
||||||
|
- **`fulfillmentConsumableSupplyOrder`** - поставки расходников
|
||||||
|
- **`fulfillmentInventoryV2`** - общий инвентарь фулфилмента
|
||||||
|
|
||||||
|
### **СВЯЗАННЫЕ КОМПОНЕНТЫ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Все эти компоненты должны использовать одинаковые данные:
|
||||||
|
|
||||||
|
1. Главный dashboard склада (/fulfillment-warehouse)
|
||||||
|
- Карточка "РАСХОДНИКИ ФУЛФИЛМЕНТА"
|
||||||
|
|
||||||
|
2. Подраздел расходников (/fulfillment-warehouse/supplies)
|
||||||
|
- Карточка "ОСТАТОК"
|
||||||
|
- Главная таблица поставок
|
||||||
|
|
||||||
|
3. Раздел услуг (/services)
|
||||||
|
- Вкладка "Расходники"
|
||||||
|
|
||||||
|
4. История поставок
|
||||||
|
- Раскрывающиеся детали каждого товара
|
||||||
|
```
|
||||||
|
|
||||||
|
### **СИНХРОНИЗИРОВАННЫЕ ПОЛЯ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SynchronizedData {
|
||||||
|
currentStock: number // Текущий остаток (одинаковый везде)
|
||||||
|
totalReceived: number // Общее количество поступлений
|
||||||
|
productId: string // ID для группировки истории поставок
|
||||||
|
fulfillmentCenterId: string // ID фулфилмент-центра
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **ПРАВИЛА ДЛЯ СТАТИСТИЧЕСКИХ КОМПОНЕНТОВ**
|
||||||
|
|
||||||
|
### **1. АРХИТЕКТУРА MASTER-DETAIL**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// MASTER компонент (главный раздел)
|
||||||
|
const masterStats = useQuery(GET_WAREHOUSE_STATS, {
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
pollInterval: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// DETAIL компонент (подраздел)
|
||||||
|
const detailStats = useQuery(GET_DETAILED_SUPPLIES, {
|
||||||
|
fetchPolicy: 'cache-and-network', // ← ОБЯЗАТЕЛЬНО то же самое!
|
||||||
|
pollInterval: 30000, // ← ОБЯЗАТЕЛЬНО то же самое!
|
||||||
|
})
|
||||||
|
|
||||||
|
// Вычисление должно давать одинаковые результаты
|
||||||
|
const masterValue = masterStats.data?.fulfillmentSupplies?.current
|
||||||
|
const detailValue = detailStats.data?.supplies?.reduce((sum, s) => sum + s.currentStock, 0)
|
||||||
|
// masterValue === detailValue ← ОБЯЗАТЕЛЬНО!
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2. ПРАВИЛА АГРЕГАЦИИ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ ПРАВИЛЬНО - агрегация из одного источника данных
|
||||||
|
const totalStock = inventoryItems.reduce((sum, item) => sum + item.currentStock, 0)
|
||||||
|
|
||||||
|
// ❌ НЕПРАВИЛЬНО - агрегация из разных источников
|
||||||
|
const totalStock1 = supplies.reduce((sum, s) => sum + s.currentStock, 0) // источник A
|
||||||
|
const totalStock2 = inventory.reduce((sum, i) => sum + i.remainingStock, 0) // источник B
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 **ПРАВИЛА СВЯЗЫВАНИЯ ДАННЫХ**
|
||||||
|
|
||||||
|
### **ОБЯЗАТЕЛЬНЫЕ ПОЛЯ ДЛЯ СВЯЗИ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// В GraphQL схемах ОБЯЗАТЕЛЬНО указывать связующие поля:
|
||||||
|
|
||||||
|
type Supply {
|
||||||
|
id: ID!
|
||||||
|
productId: ID! // ← Для фильтрации истории поставок
|
||||||
|
fulfillmentCenterId: ID // ← Для привязки к фулфилменту
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupplyOrder {
|
||||||
|
id: ID!
|
||||||
|
fulfillmentCenterId: ID! // ← Для фильтрации по фулфилменту
|
||||||
|
items: [SupplyOrderItem!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupplyOrderItem {
|
||||||
|
productId: ID! // ← Для связи с inventory
|
||||||
|
requestedQuantity: Int!
|
||||||
|
receivedQuantity: Int! // ← Для подсчёта остатков
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **ФИЛЬТРАЦИЯ ПО СВЯЗАННЫМ ДАННЫМ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ ПРАВИЛЬНО - фильтрация по ID
|
||||||
|
const getSupplyHistory = (supply: Supply) => {
|
||||||
|
return allDeliveries.filter((delivery) => delivery.items?.some((item) => item.productId === supply.productId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ НЕПРАВИЛЬНО - фильтрация по названию (может быть дубли)
|
||||||
|
const getSupplyHistory = (supply: Supply) => {
|
||||||
|
return allDeliveries.filter((delivery) => delivery.name === supply.name && delivery.category === supply.category)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ **ПРАВИЛА REAL-TIME ОБНОВЛЕНИЙ**
|
||||||
|
|
||||||
|
### **АВТОМАТИЧЕСКАЯ СИНХРОНИЗАЦИЯ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Компоненты должны обновляться автоматически при изменениях
|
||||||
|
|
||||||
|
// 1. При приёме поставки на склад
|
||||||
|
prisma.fulfillmentConsumableInventory.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: { currentStock: newStock },
|
||||||
|
})
|
||||||
|
// → Автоматически обновятся: статистика, таблица, услуги
|
||||||
|
|
||||||
|
// 2. При отгрузке товаров
|
||||||
|
prisma.fulfillmentConsumableInventory.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: { currentStock: { decrement: shippedQuantity } },
|
||||||
|
})
|
||||||
|
// → Автоматически обновятся все связанные компоненты
|
||||||
|
```
|
||||||
|
|
||||||
|
### **POLLING ИНТЕРВАЛЫ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pollInterval: 30000 // 30 сек - для статистики и dashboard
|
||||||
|
pollInterval: 60000 // 1 мин - для списков и таблиц
|
||||||
|
pollInterval: 120000 // 2 мин - для отчётов и архивных данных
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 **АНТИ-ПАТТЕРНЫ И ТИПИЧНЫЕ ОШИБКИ**
|
||||||
|
|
||||||
|
### **❌ НИКОГДА НЕ ДЕЛАЙТЕ:**
|
||||||
|
|
||||||
|
#### **1. Разные источники для одних данных**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ ПЛОХО
|
||||||
|
const stats = useQuery(GET_OLD_SUPPLIES) // legacy таблица
|
||||||
|
const details = useQuery(GET_NEW_SUPPLIES) // V2 таблица
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **2. Разные cache policies для связанных данных**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ ПЛОХО
|
||||||
|
const master = useQuery(QUERY_A, { fetchPolicy: 'cache-and-network' })
|
||||||
|
const detail = useQuery(QUERY_B, {}) // default cache-first
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **3. Ручная синхронизация через состояние**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ ПЛОХО
|
||||||
|
const [manualSync, setManualSync] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
// Попытка ручной синхронизации через state
|
||||||
|
}, [manualSync])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **4. Группировка по нестабильным полям**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ ПЛОХО - названия могут изменяться
|
||||||
|
supplies.filter((s) => s.name === targetName)
|
||||||
|
|
||||||
|
// ✅ ХОРОШО - ID стабильны
|
||||||
|
supplies.filter((s) => s.productId === targetProductId)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 **ЧЕКЛИСТ СИНХРОНИЗАЦИИ**
|
||||||
|
|
||||||
|
### **При создании связанных компонентов:**
|
||||||
|
|
||||||
|
- [ ] Используют одну таблицу БД как источник данных
|
||||||
|
- [ ] Одинаковые `fetchPolicy` настройки
|
||||||
|
- [ ] Одинаковые `pollInterval` значения
|
||||||
|
- [ ] Связь через стабильные ID поля
|
||||||
|
- [ ] Одинаковая логика агрегации/фильтрации
|
||||||
|
- [ ] Обработка ошибок и loading состояний
|
||||||
|
|
||||||
|
### **При тестировании синхронизации:**
|
||||||
|
|
||||||
|
- [ ] Изменение данных обновляет все связанные компоненты
|
||||||
|
- [ ] Значения в master и detail компонентах совпадают
|
||||||
|
- [ ] Нет задержек между обновлениями (< 30 сек)
|
||||||
|
- [ ] Ошибки в одном компоненте не ломают другие
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 **МОНИТОРИНГ СИНХРОНИЗАЦИИ**
|
||||||
|
|
||||||
|
### **DEBUG ЛОГИ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Добавляйте логи для контроля синхронизации
|
||||||
|
console.warn('SYNC CHECK:', {
|
||||||
|
masterValue: masterData?.totalStock,
|
||||||
|
detailValue: detailData?.reduce(sum, 0),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: 'fulfillmentInventorySync',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### **УВЕДОМЛЕНИЯ О РАССИНХРОНИЗАЦИИ**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Проверка консистентности данных
|
||||||
|
if (Math.abs(masterValue - detailValue) > 0) {
|
||||||
|
console.error('🚨 DATA SYNCHRONIZATION ERROR', {
|
||||||
|
master: masterValue,
|
||||||
|
detail: detailValue,
|
||||||
|
component: 'FulfillmentStats',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Отправка уведомления разработчикам
|
||||||
|
toast.error('Обнаружена рассинхронизация данных')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Следование этим правилам обеспечит надёжную синхронизацию данных между всеми компонентами системы!** 🚀
|
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