docs: добавить планы улучшения архитектуры SFERA

This commit is contained in:
Veronika Smirnova
2025-09-18 21:28:07 +03:00
parent 733ccadeb7
commit 3efc387308
18 changed files with 3130 additions and 74 deletions

View File

@ -0,0 +1,488 @@
# 🔐 ПЛАН РЕАЛИЗАЦИИ БЕЗОПАСНОЙ СИСТЕМЫ API КЛЮЧЕЙ
> **Дата:** 2025-09-18
> **Проект:** SFERA
> **Статус:** ГОТОВ К РЕАЛИЗАЦИИ
## 📊 РЕЗУЛЬТАТЫ ДИАГНОСТИКИ
### ✅ ТЕКУЩЕЕ СОСТОЯНИЕ СИСТЕМЫ:
**Данные в БД:**
- 1 активный API ключ Wildberries (длина 398 символов)
- Организация "Rennel" типа SELLER
- Ключ создан: 2025-09-17T15:19:35.423Z
- **ПРОБЛЕМА:** Хранится в открытом виде в поле `apiKey`
**UI Состояние:**
- ✅ Звездочки отображаются корректно: `••••••••••••••••••••`
- ✅ Логика переключения работает (редактирование очищает поле)
- ✅ Индикатор статуса показывает "API ключ настроен"
-**КРИТИЧНО:** handleSave НЕ СОХРАНЯЕТ API ключи
**Безопасность:**
- ❌ Ключи хранятся в открытом виде в БД
- ❌ ENCRYPTION_KEY отсутствует в .env
- ⚠️ Нужно проверить копирование звездочек
### 🎯 ЦЕЛИ РЕАЛИЗАЦИИ:
1. **Безопасность:** Зашифровать существующий API ключ
2. **Функциональность:** Исправить сохранение в handleSave
3. **UX:** Защитить от копирования звездочек
4. **Производительность:** Кэш расшифровки на 2 часа
---
## 🛡️ ПОЭТАПНЫЙ ПЛАН РЕАЛИЗАЦИИ
### 📋 ЭТАП 1: ТЕСТИРОВАНИЕ КОПИРОВАНИЯ (10 мин)
**Цель:** Убедиться что звездочки не раскрывают реальный ключ
**Задачи:**
- [ ] Запустить dev сервер
- [ ] Открыть /seller/settings в браузере
- [ ] Протестировать копирование поля с звездочками
- [ ] При необходимости добавить атрибуты блокировки
**Ожидаемый результат:** Звездочки нельзя скопировать как реальный ключ
**Критерии успеха:**
- ✅ При копировании получаются звездочки, а не настоящий ключ
- ✅ Невозможно выделить реальный ключ из поля
---
### 🔧 ЭТАП 2: ИСПРАВЛЕНИЕ СОХРАНЕНИЯ (30 мин)
**Цель:** handleSave начинает сохранять API ключи в БД
**Подготовка:**
```bash
# Backup текущего состояния
cp src/components/dashboard/user-settings.tsx src/components/dashboard/user-settings.tsx.backup
```
**Задачи:**
- [ ] Найти где обрезана функция handleSave (после строки 698)
- [ ] Добавить импорт ADD_MARKETPLACE_API_KEY в user-settings.tsx
- [ ] Добавить логику сохранения API ключей после сохранения профиля
- [ ] Протестировать сохранение через UI
**Код изменений:**
```typescript
// В импортах добавить:
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN, ADD_MARKETPLACE_API_KEY } from '@/graphql/mutations'
// В handleSave добавить после сохранения профиля:
// Сохраняем API ключи маркетплейсов
if (formData.wildberriesApiKey && formData.wildberriesApiKey !== '••••••••••••••••••••') {
await apolloClient.mutate({
mutation: ADD_MARKETPLACE_API_KEY,
variables: {
input: {
marketplace: 'WILDBERRIES',
apiKey: formData.wildberriesApiKey,
validateOnly: false
}
}
})
}
if (formData.ozonApiKey && formData.ozonApiKey !== '••••••••••••••••••••') {
await apolloClient.mutate({
mutation: ADD_MARKETPLACE_API_KEY,
variables: {
input: {
marketplace: 'OZON',
apiKey: formData.ozonApiKey,
clientId: formData.ozonClientId,
validateOnly: false
}
}
})
}
// Обновляем кэш с новыми API ключами
await apolloClient.refetchQueries({
include: [GET_ME]
})
```
**Тестирование:**
- [ ] Ввести новый WB ключ → должен сохраниться в БД
- [ ] Проверить что ключ виден в GraphQL ответе
- [ ] Проверить работу статистики с новым ключом
**Rollback план:** Восстановить из backup файла
---
### 🔐 ЭТАП 3: СОЗДАНИЕ КРИПТОСЕРВИСА (45 мин)
**Цель:** Безопасное шифрование с кэшем на 2 часа
**Подготовка:**
```bash
# Генерация мастер-ключа
openssl rand -hex 32
# Добавление в .env
echo "ENCRYPTION_KEY=сгенерированный_ключ" >> .env
```
**Задачи:**
- [ ] Создать `/src/services/crypto.service.ts`
- [ ] Реализовать AES-256-GCM шифрование
- [ ] Добавить кэш с TTL 2 часа
- [ ] Написать юнит-тесты
**Реализация CryptoService:**
```typescript
// src/services/crypto.service.ts
import crypto from 'crypto'
export class CryptoService {
private algorithm = 'aes-256-gcm'
private key: Buffer
// Кэш на 2 часа
private cache = new Map<string, { value: string; expires: number }>()
constructor() {
const masterKey = process.env.ENCRYPTION_KEY
if (!masterKey || masterKey.length !== 64) {
throw new Error('ENCRYPTION_KEY must be 64 hex characters')
}
this.key = Buffer.from(masterKey, 'hex')
}
encrypt(text: string): { encrypted: string; iv: string; tag: string } {
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return {
encrypted,
iv: iv.toString('hex'),
tag: cipher.getAuthTag().toString('hex')
}
}
decrypt(encrypted: string, iv: string, tag: string): string {
// Проверяем кэш (2 часа TTL)
const cacheKey = `${encrypted}-${iv}`
const cached = this.cache.get(cacheKey)
if (cached && cached.expires > Date.now()) {
return cached.value
}
// Расшифровываем
const decipher = crypto.createDecipheriv(
this.algorithm,
this.key,
Buffer.from(iv, 'hex')
)
decipher.setAuthTag(Buffer.from(tag, 'hex'))
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// Сохраняем в кэш на 2 часа
this.cache.set(cacheKey, {
value: decrypted,
expires: Date.now() + 2 * 60 * 60 * 1000 // 2 часа
})
// Очищаем старые записи
this.cleanCache()
return decrypted
}
private cleanCache() {
const now = Date.now()
for (const [key, item] of this.cache) {
if (item.expires < now) {
this.cache.delete(key)
}
}
}
}
```
**Тестирование:**
- [ ] Шифрование/расшифровка работает корректно
- [ ] Кэш сохраняет данные на 2 часа
- [ ] Автоочистка удаляет старые записи
---
### 🗄️ ЭТАП 4: МИГРАЦИЯ БД (30 мин)
**Цель:** Добавить поля для зашифрованных ключей
**Задачи:**
- [ ] Создать Prisma миграцию с новыми полями
- [ ] Применить миграцию к БД
- [ ] Проверить совместимость
**Новая схема:**
```prisma
model ApiKey {
id String @id @default(cuid())
marketplace MarketplaceType
// Новые поля для шифрования
encryptedKey String? // Зашифрованный ключ
encryptionIv String? // Вектор инициализации
encryptionTag String? // Тег аутентификации
lastUsedAt DateTime? // Аудит использования
// Старое поле (временно для совместимости)
apiKey String // УДАЛИТЬ после миграции
clientId String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
validationData Json?
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
@@unique([organizationId, marketplace])
@@map("api_keys")
}
```
**Команды миграции:**
```bash
npx prisma migrate dev --name add_api_keys_encryption
npx prisma generate
```
**Тестирование:**
- [ ] Миграция применяется без ошибок
- [ ] Существующий ключ остается рабочим
- [ ] Новые поля готовы к использованию
**Rollback:** `npx prisma migrate reset` (данные не критичны)
---
### 🔄 ЭТАП 5: ОБНОВЛЕНИЕ РЕЗОЛВЕРОВ (45 мин)
**Цель:** Использовать шифрование в GraphQL
**Задачи:**
- [ ] Обновить addMarketplaceApiKey для шифрования новых ключей
- [ ] Обновить getWildberriesStatistics для расшифровки
- [ ] Добавить fallback на старые ключи
- [ ] Протестировать API
**Изменения в wildberries.ts:**
```typescript
import { CryptoService } from '../../../services/crypto.service'
const cryptoService = new CryptoService()
// В addMarketplaceApiKey:
if (existing) {
// Шифруем новый ключ
const encrypted = cryptoService.encrypt(apiKey)
await prisma.apiKey.update({
where: { id: existing.id },
data: {
encryptedKey: encrypted.encrypted,
encryptionIv: encrypted.iv,
encryptionTag: encrypted.tag,
apiKey: null, // Очищаем старое поле
isActive: true
}
})
}
// В getWildberriesStatistics:
let decryptedKey: string
if (apiKey.encryptedKey && apiKey.encryptionIv && apiKey.encryptionTag) {
// Новый зашифрованный ключ
decryptedKey = cryptoService.decrypt(
apiKey.encryptedKey,
apiKey.encryptionIv,
apiKey.encryptionTag
)
} else if (apiKey.apiKey) {
// Fallback на старый незашифрованный ключ
decryptedKey = apiKey.apiKey
} else {
return { success: false, message: 'API ключ не найден' }
}
```
**Тестирование:**
- [ ] Новые ключи шифруются при сохранении
- [ ] Старый ключ работает через fallback
- [ ] Статистика загружается корректно
- [ ] Кэш работает (повторные запросы быстрее)
---
### 📦 ЭТАП 6: МИГРАЦИЯ СУЩЕСТВУЮЩЕГО КЛЮЧА (15 мин)
**Цель:** Перешифровать единственный ключ в БД
**Задачи:**
- [ ] Создать скрипт миграции
- [ ] Выполнить перешифровку
- [ ] Убрать fallback логику
- [ ] Финальное тестирование
**Скрипт миграции:**
```javascript
// scripts/migrate-existing-api-keys.js
import { PrismaClient } from '@prisma/client'
import { CryptoService } from '../src/services/crypto.service.js'
const prisma = new PrismaClient()
const cryptoService = new CryptoService()
async function migrateApiKeys() {
const keys = await prisma.apiKey.findMany({
where: {
apiKey: { not: null },
encryptedKey: null
}
})
console.log(`🔄 Найдено ключей для миграции: ${keys.length}`)
for (const key of keys) {
if (!key.apiKey) continue
const encrypted = cryptoService.encrypt(key.apiKey)
await prisma.apiKey.update({
where: { id: key.id },
data: {
encryptedKey: encrypted.encrypted,
encryptionIv: encrypted.iv,
encryptionTag: encrypted.tag,
apiKey: null // Очищаем старое поле
}
})
console.log(`✅ Мигрирован ключ ${key.id} для ${key.marketplace}`)
}
console.log('🎉 Миграция завершена')
}
migrateApiKeys().catch(console.error)
```
**Выполнение:**
```bash
node scripts/migrate-existing-api-keys.js
```
**Тестирование:**
- [ ] Существующий ключ перешифрован
- [ ] Статистика продолжает работать
- [ ] В БД нет незашифрованных ключей
- [ ] Fallback больше не используется
---
## 🧪 ПЛАН ТЕСТИРОВАНИЯ
### После каждого этапа проверять:
**1. Основная функциональность:**
- [ ] Авторизация в кабинет селлера работает
- [ ] Настройки → API ключи открываются
- [ ] Статистика Wildberries загружается
**2. Сохранение ключей:**
- [ ] Ввод нового WB ключа сохраняется
- [ ] Настройки сохраняются без ошибок
- [ ] Ключ появляется в БД
**3. Безопасность:**
- [ ] Звездочки показываются вместо ключей
- [ ] Копирование не раскрывает реальные ключи
- [ ] GraphQL не возвращает расшифрованные ключи
- [ ] Ошибки не логируют реальные ключи
**4. Производительность:**
- [ ] Первый запрос статистики (расшифровка)
- [ ] Повторный запрос (из кэша) быстрее
- [ ] Память не растет при множественных запросах
---
## ⚠️ КРИТИЧЕСКИЕ МОМЕНТЫ
### 🚨 ТОЧКИ ОСТАНОВКИ:
- Статистика перестала загружаться
- Ошибки сохранения в настройках
- Потеря существующего API ключа
- Любые 500 ошибки в GraphQL
- Ошибки шифрования/расшифровки
### 🔄 ROLLBACK ПЛАНЫ:
**Этап 2:** Восстановить user-settings.tsx из backup
**Этап 3:** Не применять к продакшену
**Этап 4:** `npx prisma migrate reset`
**Этап 5:** Откатить изменения резолверов
**Этап 6:** Восстановить из backup БД
### 💾 BACKUP ФАЙЛЫ:
-`api_keys_backup_1758196936364.json` - данные БД
-`user-settings.tsx.backup` - будет создан на этапе 2
---
## 🎯 ОЖИДАЕМЫЙ РЕЗУЛЬТАТ
### После завершения всех этапов:
**✅ Безопасность:**
- API ключи зашифрованы в БД алгоритмом AES-256-GCM
- UI показывает только звездочки
- Копирование не раскрывает реальные ключи
- GraphQL не возвращает расшифрованные ключи
**✅ Функциональность:**
- Сохранение API ключей через UI работает
- Статистика Wildberries загружается корректно
- Существующий ключ мигрирован без потерь
**✅ Производительность:**
- Кэш расшифровки на 2 часа
- Автоочистка старых записей кэша
- Быстрые повторные запросы
**✅ Аудит:**
- Поле lastUsedAt отслеживает использование
- Логи не содержат реальных ключей
- Безопасное хранение в production
---
## 📋 ЧЕКЛИСТ ГОТОВНОСТИ
Перед началом убедиться:
- [ ] Проект запускается локально
- [ ] БД доступна и работает
- [ ] Есть доступ к кабинету селлера
- [ ] Backup данных создан
- [ ] ENCRYPTION_KEY сгенерирован
**План готов к исполнению! 🚀**
---
*Документ создан: 2025-09-18*
*Проект: SFERA Marketplace Platform*
*Статус: Готов к реализации*

View File

@ -0,0 +1,304 @@
# 📋 ПЛАН УЛУЧШЕНИЯ АРХИТЕКТУРЫ АВТОРИЗАЦИИ И БЕЗОПАСНОСТИ SFERA
**Дата создания:** 2025-09-18
**Автор:** Claude AI + Вероника Смирнова
**Статус:** В разработке
## 📊 Анализ текущего состояния
### ✅ Что уже реализовано и работает:
1. **Безопасные ID (CUID)**
- Все модели используют `@default(cuid())`
- Невозможно угадать ID других организаций
- Пример: `cmfpe46iv0001y51d87f4vy2n`
2. **Защита типов кабинетов**
- `useRoleGuard` на 49 страницах
- Автоматический редирект при попытке доступа к чужому типу кабинета
- Селлер не может зайти в кабинет Поставщика
3. **Изоляция данных на уровне API**
- Все резолверы фильтруют по `organizationId`
- Каждая организация видит только свои данные
- Проверки в каждом GraphQL резолвере
4. **Структура роутинга**
- Четкое разделение: `/seller/*`, `/fulfillment/*`, `/logistics/*`, `/wholesale/*`
- DashboardHome автоматически направляет в нужный кабинет
### 🚨 Выявленные проблемы:
1. **Отсутствие глобального состояния (AuthContext)**
- Каждый компонент создает свой экземпляр useAuth
- Состояние не синхронизируется между компонентами
- Sidebar не видит данные пользователя после авторизации
2. **Дублирование кода безопасности**
- Одинаковые проверки в 50+ резолверах
- Риск забыть добавить проверку в новый резолвер
- Сложность поддержки
3. **Отсутствие персистентности**
- При обновлении страницы состояние теряется
- Повторные запросы GET_ME
- Плохой UX при F5
4. **Нет серверной проверки роутов**
- Проверки только на уровне компонентов
- Страница начинает загружаться до проверки прав
## 🎯 План улучшений
### ФАЗА 1: Критические исправления
#### 1. Реализация AuthContext [🔴 КРИТИЧНО]
**Срок:** 2-3 часа
**Приоритет:** Максимальный
**Влияние:** Решает проблему с sidebar и синхронизацией состояния
**План реализации:**
1. Создать `/src/contexts/AuthContext.tsx`:
```typescript
import { createContext, useContext, useState, useEffect } from 'react'
import { useApolloClient } from '@apollo/client'
interface AuthContextType {
user: User | null
isAuthenticated: boolean
isLoading: boolean
checkAuth: () => Promise<void>
login: (phone: string, code: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }) {
// Перенести всю логику из текущего useAuth
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
// ... вся логика авторизации
return (
<AuthContext.Provider value={{ user, ... }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
```
2. Обновить `/src/app/providers.tsx`:
```typescript
export function Providers({ children }) {
return (
<ApolloProvider client={apolloClient}>
<AuthProvider>
<SidebarProvider>
{children}
</SidebarProvider>
</AuthProvider>
</ApolloProvider>
)
}
```
3. Убрать временные решения:
- Удалить `useQuery(GET_ME)` из AppShell
- Убрать передачу user через props
- Вернуть оригинальную логику компонентов
#### 2. GraphQL Middleware для безопасности [🟡 ВАЖНО]
**Срок:** 1 день
**Приоритет:** Высокий
**Влияние:** Централизованная безопасность, чистый код
**План реализации:**
1. Создать `/src/graphql/middleware/organizationAccess.ts`:
```typescript
export const organizationAccessMiddleware = async (
resolve,
parent,
args,
context,
info
) => {
// Пропускаем публичные операции
const PUBLIC_OPERATIONS = ['login', 'sendSmsCode', 'verifySmsCode']
if (PUBLIC_OPERATIONS.includes(info.fieldName)) {
return resolve(parent, args, context, info)
}
// Проверяем авторизацию
if (!context.user?.organizationId) {
throw new GraphQLError('Unauthorized', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
// Автоматически добавляем organizationId к запросам
if (args.where && typeof args.where === 'object') {
args.where.organizationId = context.user.organizationId
}
// Логирование для безопасности
console.log(`[${context.user.organizationId}] ${info.fieldName}`, {
userId: context.user.id,
operation: info.fieldName,
timestamp: new Date().toISOString()
})
return resolve(parent, args, context, info)
}
```
2. Применить middleware ко всем резолверам
3. Удалить дублированные проверки из резолверов
### ФАЗА 2: Важные улучшения
#### 3. Персистентность состояния [🟡 ВАЖНО]
**Срок:** 0.5 дня
**Приоритет:** Средний
**Влияние:** Улучшение UX при обновлении страницы
**План реализации:**
1. Добавить в AuthContext сохранение состояния:
```typescript
// При успешной авторизации
const encryptedUser = encrypt(JSON.stringify(user))
localStorage.setItem('auth:user', encryptedUser)
// При инициализации
const savedUser = localStorage.getItem('auth:user')
if (savedUser) {
const user = JSON.parse(decrypt(savedUser))
// Проверить валидность токена
}
```
2. Реализовать refresh token механизм
3. Очистка при logout
#### 4. Next.js Middleware [🟢 ЖЕЛАТЕЛЬНО]
**Срок:** 1 день
**Приоритет:** Средний
**Влияние:** Производительность, серверная защита
**План реализации:**
1. Создать `/src/middleware.ts`:
```typescript
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value
const pathname = request.nextUrl.pathname
// Публичные маршруты
const publicPaths = ['/', '/login', '/register']
if (publicPaths.includes(pathname)) return
// Проверка токена
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET!))
const orgType = payload.organization?.type
// Проверка доступа к кабинету
if (pathname.startsWith('/seller') && orgType !== 'SELLER') {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// ... остальные проверки
} catch (error) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}
```
### ФАЗА 3: Оптимизации
#### 5. Улучшение загрузки [🔵 ОПЦИОНАЛЬНО]
**Срок:** 1 неделя
**Приоритет:** Низкий
**Влияние:** Улучшение UX
- Skeleton screens для всех страниц
- Prefetch критических данных
- Оптимистичные обновления UI
- PWA функциональность
#### 6. Расширенная система прав [🔵 ОПЦИОНАЛЬНО]
**Срок:** 2 недели
**Приоритет:** Низкий
**Влияние:** Enterprise функциональность
- Роли: Admin, Manager, Viewer
- Permissions: canEdit, canDelete, canApprove
- Аудит всех действий
- Делегирование доступа
## 📈 Ожидаемые результаты
### После ФАЗЫ 1:
- ✅ Sidebar работает стабильно
- ✅ Состояние синхронизировано между компонентами
- ✅ Единая точка контроля безопасности
- ✅ Чистый, поддерживаемый код
### После ФАЗЫ 2:
- ✅ Мгновенная загрузка после F5
- ✅ Защита на уровне сервера
- ✅ Автоматическое управление сессией
### После ФАЗЫ 3:
- ✅ Премиум UX
- ✅ Enterprise-ready система
- ✅ Готовность к масштабированию
## 🚀 Рекомендуемый порядок выполнения
1. **Неделя 1:** AuthContext (решает критическую проблему)
2. **Неделя 2:** GraphQL Middleware + Персистентность
3. **Месяц 2:** Next.js Middleware + Оптимизации по необходимости
## 📝 Критерии успеха
- [ ] Sidebar отображается сразу после авторизации
- [ ] Нет дублирования состояния между компонентами
- [ ] Единая проверка безопасности для всех API запросов
- [ ] Состояние сохраняется при обновлении страницы
- [ ] Производительность не ухудшилась
- [ ] Код стал чище и проще в поддержке
---
*Документ будет обновляться по мере реализации плана*

View File

@ -0,0 +1,505 @@
# 🔐 ПЛАН БЕЗОПАСНОЙ МИГРАЦИИ НА AuthContext
**Дата создания:** 2025-09-18
**Автор:** Claude AI + Вероника Смирнова
**Статус:** Готов к реализации
## 📊 Результаты глубокой диагностики
### Анализ текущей реализации useAuth
**Размер и сложность:**
- 657 строк кода
- 65 файлов используют useAuth()
- Сложная логика с rollback механизмами
- Интеграция с Apollo Client через localStorage
**Ключевые компоненты:**
1. **State Management**
- `user: User | null` - данные пользователя
- `isAuthenticated: boolean` - статус авторизации
- `isLoading: boolean` - индикатор загрузки
- `isCheckingAuth: boolean` - защита от дублирования
2. **Методы авторизации**
- `sendSmsCode` - отправка SMS
- `verifySmsCode` - проверка кода
- `checkAuth` - проверка текущей сессии
- `logout` - выход
3. **Методы регистрации**
- `registerFulfillmentOrganization`
- `registerSellerOrganization`
- `registerOrganization` (универсальный)
4. **Интеграции**
- Apollo Client для GraphQL
- localStorage для токенов
- refreshApolloClient для синхронизации
### Выявленные проблемы
1. **Множественные экземпляры состояния**
```
AppShell → useAuth() → useState (копия 1)
Sidebar → useAuth() → useState (копия 2)
Component → useAuth() → useState (копия 3)
```
2. **Race conditions**
- checkAuth вызывается параллельно из разных компонентов
- isCheckingAuth защищает только локальный экземпляр
3. **Отсутствие синхронизации**
- Обновления в одном компоненте не видны в других
- GET_ME выполняется многократно
4. **Проблемы с SSR**
- Прямое обращение к localStorage
- window checks разбросаны по коду
## 🎯 Архитектура решения
### Новая структура с AuthContext
```
AuthProvider (глобальное состояние)
├── Apollo Provider
│ └── Auth Link (токены из контекста)
├── State Management
│ ├── user
│ ├── isAuthenticated
│ └── isLoading
└── Methods
├── Authentication
├── Registration
└── Session Management
```
### Преимущества
1. **Единое состояние** - все компоненты видят одни данные
2. **Оптимизация запросов** - GET_ME выполняется 1 раз
3. **Синхронизация** - изменения видны везде мгновенно
4. **SSR совместимость** - централизованные проверки
5. **Типобезопасность** - строгая типизация контекста
## 📋 Поэтапный план миграции
### ЭТАП 0: Подготовка [30 мин]
**Цель:** Создать безопасную среду для миграции
1. **Создать backup текущего состояния**
```bash
git add .
git commit -m "backup: перед миграцией на AuthContext"
git branch backup-before-auth-context
```
2. **Создать feature branch**
```bash
git checkout -b feature/auth-context-migration
```
3. **Подготовить структуру папок**
```
src/
├── contexts/
│ └── auth/
│ ├── AuthContext.tsx # Основной контекст
│ ├── AuthProvider.tsx # Provider компонент
│ ├── types.ts # TypeScript типы
│ └── utils.ts # Вспомогательные функции
```
### ЭТАП 1: Создание AuthContext с минимальной функциональностью [1 час]
**Цель:** Создать работающий контекст без нарушения существующего функционала
1. **Создать типы** (`src/contexts/auth/types.ts`)
```typescript
export interface User {
id: string
phone: string
avatar?: string
managerName?: string
organization?: Organization
}
export interface Organization {
id: string
inn: string
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
// ... остальные поля
}
export interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
}
export interface AuthContextType extends AuthState {
// Методы будем добавлять постепенно
checkAuth: () => Promise<void>
logout: () => void
}
```
2. **Создать контекст** (`src/contexts/auth/AuthContext.tsx`)
```typescript
import { createContext } from 'react'
import type { AuthContextType } from './types'
export const AuthContext = createContext<AuthContextType | null>(null)
```
3. **Создать базовый Provider** (`src/contexts/auth/AuthProvider.tsx`)
```typescript
import { useState, useCallback, useEffect } from 'react'
import { AuthContext } from './AuthContext'
import { getAuthToken, removeAuthToken } from '@/lib/apollo-client'
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// Минимальная реализация checkAuth
const checkAuth = useCallback(async () => {
const token = getAuthToken()
if (!token) {
setIsAuthenticated(false)
setUser(null)
setIsLoading(false)
return
}
// TODO: Добавить GET_ME запрос
setIsLoading(false)
}, [])
// Минимальная реализация logout
const logout = useCallback(() => {
removeAuthToken()
setUser(null)
setIsAuthenticated(false)
window.location.href = '/'
}, [])
useEffect(() => {
checkAuth()
}, [checkAuth])
const value = {
user,
isAuthenticated,
isLoading,
checkAuth,
logout
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
```
4. **Создать временный хук-обертку** (`src/hooks/useAuth.ts`)
```typescript
// В начале файла добавляем
import { useContext } from 'react'
import { AuthContext } from '@/contexts/auth/AuthContext'
// Временный флаг для постепенной миграции
const USE_AUTH_CONTEXT = false
export const useAuth = (): UseAuthReturn => {
if (USE_AUTH_CONTEXT) {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
// Адаптер для совместимости API
return {
...context,
// Методы-заглушки для совместимости
sendSmsCode: async () => ({ success: false, message: 'Not implemented' }),
verifySmsCode: async () => ({ success: false, message: 'Not implemented' }),
registerFulfillmentOrganization: async () => ({ success: false, message: 'Not implemented' }),
registerSellerOrganization: async () => ({ success: false, message: 'Not implemented' }),
registerOrganization: async () => ({ success: false, message: 'Not implemented' }),
updateUser: () => {}
}
}
// Существующая реализация остается без изменений
// ... весь текущий код
}
```
### ЭТАП 2: Тестирование базовой интеграции [30 мин]
**Цель:** Убедиться что ничего не сломалось
1. **Добавить AuthProvider в providers.tsx**
```typescript
import { AuthProvider } from '@/contexts/auth/AuthProvider'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ApolloProvider client={apolloClient}>
<AuthProvider>
<SidebarProvider>
{children}
</SidebarProvider>
</AuthProvider>
</ApolloProvider>
)
}
```
2. **Включить USE_AUTH_CONTEXT для одного компонента**
- Начать с простого компонента (например, UserProfile в sidebar)
- Проверить что компонент рендерится
- Проверить что нет ошибок в консоли
3. **Rollback план**
- Если есть ошибки - установить USE_AUTH_CONTEXT = false
- Исправить проблемы
- Повторить тестирование
### ЭТАП 3: Миграция основного функционала [2 часа]
**Цель:** Перенести всю логику в AuthContext
1. **Перенести checkAuth с GET_ME**
```typescript
const checkAuth = useCallback(async () => {
if (isCheckingAuth.current) return
const token = getAuthToken()
if (!token) {
setIsAuthenticated(false)
setUser(null)
setIsLoading(false)
return
}
isCheckingAuth.current = true
setIsLoading(true)
try {
const { data } = await apolloClient.query({
query: GET_ME,
errorPolicy: 'all',
fetchPolicy: 'network-only'
})
if (data?.me) {
setUser(data.me)
setIsAuthenticated(true)
setUserData(data.me)
}
} catch (error) {
// Обработка ошибок
} finally {
isCheckingAuth.current = false
setIsLoading(false)
}
}, [])
```
2. **Перенести SMS методы**
- sendSmsCode
- verifySmsCode
- Сохранить всю логику с логированием
3. **Перенести методы регистрации**
- registerFulfillmentOrganization
- registerSellerOrganization
- registerOrganization
- Сохранить rollback механизмы
4. **Добавить updateUser**
```typescript
const updateUser = useCallback((updatedUser: Partial<User>) => {
setUser(current => {
if (!current) return current
const updated = { ...current, ...updatedUser }
setUserData(updated) // Синхронизация с localStorage
return updated
})
}, [])
```
### ЭТАП 4: Постепенная миграция компонентов [1 день]
**Цель:** Безопасно перевести все компоненты на новую систему
1. **Приоритетные компоненты** (первая очередь)
- AppShell
- Sidebar и все его варианты
- AuthGuard
2. **Критические компоненты** (вторая очередь)
- Страницы авторизации (login, register)
- DashboardHome
- useRoleGuard
3. **Остальные компоненты** (третья очередь)
- Разбить на группы по 10-15 файлов
- Мигрировать группами
- Тестировать после каждой группы
**Процесс для каждого компонента:**
1. Включить USE_AUTH_CONTEXT локально
2. Проверить функциональность
3. Если работает - коммит
4. Если нет - откат и исправление
### ЭТАП 5: Оптимизация и очистка [1 час]
**Цель:** Удалить старый код и оптимизировать
1. **Удалить старую реализацию из useAuth**
- Оставить только обертку для контекста
- Удалить локальные useState
- Удалить дублированную логику
2. **Оптимизировать рендеринг**
```typescript
// Мемоизация значения контекста
const value = useMemo(() => ({
user,
isAuthenticated,
isLoading,
checkAuth,
logout,
// ... другие методы
}), [user, isAuthenticated, isLoading])
```
3. **Добавить DevTools**
```typescript
if (process.env.NODE_ENV === 'development') {
(window as any).__AUTH_STATE__ = { user, isAuthenticated }
}
```
### ЭТАП 6: Финальное тестирование [1 час]
**Цель:** Убедиться что все работает
1. **Функциональные тесты**
- [ ] Авторизация по SMS
- [ ] Регистрация всех типов организаций
- [ ] Отображение sidebar
- [ ] Переходы между страницами
- [ ] Выход из системы
- [ ] Обновление страницы (F5)
2. **Тесты производительности**
- [ ] Нет множественных GET_ME запросов
- [ ] Нет лишних ре-рендеров
- [ ] Быстрая загрузка после F5
3. **Регрессионные тесты**
- [ ] Все 65 компонентов работают
- [ ] API ключи сохраняются
- [ ] Роутинг по типам организаций
## 🚨 Риски и митигация
### Риск 1: Поломка авторизации
**Митигация:**
- Постепенная миграция через флаг USE_AUTH_CONTEXT
- Возможность быстрого отката
- Тестирование на каждом этапе
### Риск 2: Потеря состояния
**Митигация:**
- Сохранение в localStorage остается
- Rollback механизмы сохраняются
- Логирование всех изменений
### Риск 3: Проблемы с SSR
**Митигация:**
- Все проверки window в одном месте
- useEffect для клиентских операций
- Правильная инициализация состояния
### Риск 4: Race conditions
**Митигация:**
- useRef для флагов загрузки
- Отмена дублированных запросов
- Правильная очередность операций
## 📊 Метрики успеха
1. **Функциональность**
- ✅ Все 65 компонентов работают
- ✅ Sidebar отображается корректно
- ✅ Авторизация стабильна
2. **Производительность**
- ✅ GET_ME вызывается 1 раз
- ✅ Нет задержек при навигации
- ✅ Быстрая загрузка после F5
3. **Качество кода**
- ✅ Единое место управления состоянием
- ✅ Типобезопасность
- ✅ Отсутствие дублирования
## 🔄 Rollback план
Если что-то пойдет не так на любом этапе:
1. **Быстрый откат**
```bash
git checkout backup-before-auth-context
```
2. **Частичный откат**
- Установить USE_AUTH_CONTEXT = false
- Вернуть проблемные компоненты на старую версию
- Исправить проблемы в изолированной ветке
3. **Восстановление данных**
- localStorage сохраняется
- Токены остаются валидными
- Пользователи не заметят проблем
## 📝 Чек-лист готовности
Перед началом миграции убедитесь:
- [ ] Создан backup текущего состояния
- [ ] Команда предупреждена о работах
- [ ] Подготовлен план коммуникации при проблемах
- [ ] Есть доступ к логам и мониторингу
- [ ] Определено время для миграции (лучше в период низкой активности)
## 🎯 Ожидаемый результат
После завершения миграции:
1. **Немедленные улучшения**
- Sidebar работает стабильно
- Состояние синхронизировано между компонентами
- Уменьшено количество запросов к API
2. **Долгосрочные преимущества**
- Готовность к добавлению новых функций
- Упрощенная отладка
- Лучшая производительность
- Возможность добавления продвинутых функций (персистентность, refresh tokens)
---
*Документ будет обновляться по ходу выполнения миграции*

View File

@ -28,7 +28,7 @@ export function AuthGuard({ children, fallback }: AuthGuardProps) {
}
initAuth()
}, [checkAuth, isAuthenticated, user]) // Добавляем зависимости как требует линтер
}, []) // Запускаем только один раз при монтировании
// Показываем лоадер пока проверяем авторизацию

View File

@ -24,8 +24,11 @@ function FulfillmentSuppliesNotification({ count }: { count: number }) {
)
}
export function FulfillmentSidebar() {
const { user, logout } = useAuth()
export function FulfillmentSidebar({ user: propUser }: { user?: any } = {}) {
const { user: hookUser, logout } = useAuth()
// Приоритет: переданный user через props, затем из хука
const user = propUser || hookUser
const router = useRouter()
const pathname = usePathname()
const { isCollapsed, toggleSidebar } = useSidebar()

View File

@ -13,8 +13,11 @@ import { UserProfile } from './core/UserProfile'
import { useSidebarData } from './hooks/useSidebarData'
import { logistNavigation } from './navigations/logist'
export function LogistSidebar() {
const { user, logout } = useAuth()
export function LogistSidebar({ user: propUser }: { user?: any } = {}) {
const { user: hookUser, logout } = useAuth()
// Приоритет: переданный user через props, затем из хука
const user = propUser || hookUser
const router = useRouter()
const pathname = usePathname()
const { isCollapsed, toggleSidebar } = useSidebar()

View File

@ -13,8 +13,11 @@ import { UserProfile } from './core/UserProfile'
import { useSidebarData } from './hooks/useSidebarData'
import { sellerNavigation } from './navigations/seller'
export function SellerSidebar() {
const { user, logout } = useAuth()
export function SellerSidebar({ user: propUser }: { user?: any } = {}) {
const { user: hookUser, logout } = useAuth()
// Приоритет: переданный user через props, затем из хука
const user = propUser || hookUser
const router = useRouter()
const pathname = usePathname()
const { isCollapsed, toggleSidebar } = useSidebar()

View File

@ -24,8 +24,11 @@ function WholesaleOrdersNotification({ count }: { count: number }) {
)
}
export function WholesaleSidebar() {
const { user, logout } = useAuth()
export function WholesaleSidebar({ user: propUser }: { user?: any } = {}) {
const { user: hookUser, logout } = useAuth()
// Приоритет: переданный user через props, затем из хука
const user = propUser || hookUser
const router = useRouter()
const pathname = usePathname()
const { isCollapsed, toggleSidebar } = useSidebar()

View File

@ -13,8 +13,32 @@ declare global {
}
}
export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean } = {}) {
const { user } = useAuth()
export function Sidebar({
isRootInstance = false,
user: propUser
}: {
isRootInstance?: boolean,
user?: any
} = {}) {
const { user: hookUser } = useAuth()
// Приоритет: переданный user через props, затем из хука
const user = propUser || hookUser
console.warn('Sidebar render:', {
isRootInstance,
hasUser: !!user,
hasPropUser: !!propUser,
hasHookUser: !!hookUser,
userPhone: user?.phone,
organizationType: user?.organization?.type,
willRender: !!user?.organization?.type
})
// Диагностика почему user пустой
if (!user) {
console.warn('🔴 Sidebar: NO USER DATA', { propUser, hookUser })
}
// Если уже есть корневой сайдбар и это не корневой экземпляр — не рендерим дубликат
if (
@ -22,6 +46,7 @@ export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean }
!isRootInstance &&
window.__SIDEBAR_ROOT_MOUNTED__
) {
console.warn('Sidebar: duplicate instance, not rendering')
return null
}
@ -31,22 +56,23 @@ export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean }
}
if (!user?.organization?.type) {
console.warn('Sidebar: no user or organization type, not rendering')
return null
}
// Роутинг по типам организаций
switch (user.organization.type) {
case 'LOGIST':
return <LogistSidebar />
return <LogistSidebar user={user} />
case 'SELLER':
return <SellerSidebar />
return <SellerSidebar user={user} />
case 'FULFILLMENT':
return <FulfillmentSidebar />
return <FulfillmentSidebar user={user} />
case 'WHOLESALE':
return <WholesaleSidebar />
return <WholesaleSidebar user={user} />
default:
return null

View File

@ -31,7 +31,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations'
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN, ADD_MARKETPLACE_API_KEY } from '@/graphql/mutations'
import { GET_ME } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useSidebar } from '@/hooks/useSidebar'
@ -687,13 +687,59 @@ export function UserSettings() {
if (result.data?.updateUserProfile?.success) {
setSaveMessage({
type: 'success',
text: 'Профиль успешно сохранен! Обновляем страницу...',
text: 'Профиль успешно сохранен! Сохраняем API ключи...',
})
// Сохраняем API ключи маркетплейсов
try {
if (formData.wildberriesApiKey && formData.wildberriesApiKey !== '••••••••••••••••••••') {
await apolloClient.mutate({
mutation: ADD_MARKETPLACE_API_KEY,
variables: {
input: {
marketplace: 'WILDBERRIES',
apiKey: formData.wildberriesApiKey,
validateOnly: false,
},
},
})
}
if (formData.ozonApiKey && formData.ozonApiKey !== '••••••••••••••••••••') {
await apolloClient.mutate({
mutation: ADD_MARKETPLACE_API_KEY,
variables: {
input: {
marketplace: 'OZON',
apiKey: formData.ozonApiKey,
clientId: formData.ozonClientId,
validateOnly: false,
},
},
})
}
// Обновляем кэш с новыми API ключами
await apolloClient.refetchQueries({
include: [GET_ME],
})
setSaveMessage({
type: 'success',
text: 'Профиль и API ключи успешно сохранены! Обновляем страницу...',
})
} catch (apiError) {
console.error('Error saving API keys:', apiError)
setSaveMessage({
type: 'success',
text: 'Профиль сохранен, но возникла ошибка с API ключами. Обновляем страницу...',
})
}
// Простое обновление страницы после успешного сохранения
setTimeout(() => {
window.location.reload()
}, 1000)
}, 1500)
} else {
setSaveMessage({
type: 'error',
@ -1497,6 +1543,8 @@ export function UserSettings() {
placeholder="Введите API ключ Wildberries"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
autoComplete="off"
spellCheck="false"
/>
{(user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES') ||
(formData.wildberriesApiKey && isEditing)) && (
@ -1521,6 +1569,8 @@ export function UserSettings() {
placeholder="Введите API ключ Ozon"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
autoComplete="off"
spellCheck="false"
/>
{(user?.organization?.apiKeys?.find((key) => key.marketplace === 'OZON') ||
(formData.ozonApiKey && isEditing)) && (

View File

@ -0,0 +1,1579 @@
'use client'
import { useMutation } from '@apollo/client'
import {
User,
Building2,
Phone,
Mail,
MapPin,
CreditCard,
Key,
Edit3,
CheckCircle,
AlertTriangle,
MessageCircle,
Save,
RefreshCw,
Calendar,
Settings,
Camera,
} from 'lucide-react'
import Image from 'next/image'
import { useState, useEffect, useRef } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations'
import { GET_ME } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useSidebar } from '@/hooks/useSidebar'
import { apolloClient } from '@/lib/apollo-client'
import { formatPhone } from '@/lib/utils'
import S3Service from '@/services/s3-service'
import { Sidebar } from './sidebar'
export function UserSettings() {
const { getSidebarMargin } = useSidebar()
const { user, updateUser } = useAuth()
const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE)
const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN)
const [isEditing, setIsEditing] = useState(false)
const [saveMessage, setSaveMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [localAvatarUrl, setLocalAvatarUrl] = useState<string | null>(null)
const phoneInputRef = useRef<HTMLInputElement | null>(null)
const whatsappInputRef = useRef<HTMLInputElement>(null)
// Инициализируем данные из пользователя и организации
const [formData, setFormData] = useState({
// Контактные данные организации
orgPhone: '', // телефон организации, не пользователя
managerName: '',
telegram: '',
whatsapp: '',
email: '',
// Организация - данные могут быть заполнены из DaData
orgName: '',
address: '',
// Юридические данные - могут быть заполнены из DaData
fullName: '',
inn: '',
ogrn: '',
registrationPlace: '',
// Финансовые данные - требуют ручного заполнения
bankName: '',
bik: '',
accountNumber: '',
corrAccount: '',
// API ключи маркетплейсов
wildberriesApiKey: '',
ozonApiKey: '',
// Рынок для поставщиков
market: '',
})
// Загружаем данные организации при монтировании компонента
useEffect(() => {
if (user?.organization) {
const org = user.organization
// Извлекаем первый телефон из phones JSON
let orgPhone = ''
if (org.phones && Array.isArray(org.phones) && org.phones.length > 0) {
orgPhone = org.phones[0].value || org.phones[0] || ''
} else if (org.phones && typeof org.phones === 'object') {
const phoneValues = Object.values(org.phones)
if (phoneValues.length > 0) {
orgPhone = String(phoneValues[0])
}
}
// Извлекаем email из emails JSON
let email = ''
if (org.emails && Array.isArray(org.emails) && org.emails.length > 0) {
email = org.emails[0].value || org.emails[0] || ''
} else if (org.emails && typeof org.emails === 'object') {
const emailValues = Object.values(org.emails)
if (emailValues.length > 0) {
email = String(emailValues[0])
}
}
// Извлекаем дополнительные данные из managementPost (JSON)
let customContacts: {
managerName?: string
telegram?: string
whatsapp?: string
bankDetails?: {
bankName?: string
bik?: string
accountNumber?: string
corrAccount?: string
}
} = {}
try {
if (org.managementPost && typeof org.managementPost === 'string') {
// Проверяем, что строка начинается с { или [, иначе это не JSON
if (org.managementPost.trim().startsWith('{') || org.managementPost.trim().startsWith('[')) {
customContacts = JSON.parse(org.managementPost)
}
}
} catch {
// Игнорируем ошибки парсинга
}
setFormData({
orgPhone: orgPhone || '+7',
managerName: user?.managerName || '',
telegram: customContacts?.telegram || '',
whatsapp: customContacts?.whatsapp || '',
email: email,
orgName: org.name || '',
address: org.address || '',
fullName: org.fullName || '',
inn: org.inn || '',
ogrn: org.ogrn || '',
registrationPlace: org.address || '',
bankName: customContacts?.bankDetails?.bankName || '',
bik: customContacts?.bankDetails?.bik || '',
accountNumber: customContacts?.bankDetails?.accountNumber || '',
corrAccount: customContacts?.bankDetails?.corrAccount || '',
wildberriesApiKey: '',
ozonApiKey: '',
market: (org as any).market || 'none',
})
}
}, [user])
const getInitials = () => {
const orgName = user?.organization?.name || user?.organization?.fullName
if (orgName) {
return orgName.charAt(0).toUpperCase()
}
return user?.phone ? user.phone.slice(-2).toUpperCase() : 'О'
}
const getCabinetTypeName = () => {
if (!user?.organization?.type) return 'Не указан'
switch (user.organization.type) {
case 'FULFILLMENT':
return 'Фулфилмент'
case 'SELLER':
return 'Селлер'
case 'LOGIST':
return 'Логистика'
case 'WHOLESALE':
return 'Поставщик'
default:
return 'Не указан'
}
}
// Обновленная функция для проверки заполненности профиля
const checkProfileCompleteness = () => {
// Базовые поля (обязательные для всех)
const baseFields = [
{
field: 'orgPhone',
label: 'Телефон организации',
value: formData.orgPhone,
},
{
field: 'managerName',
label: 'Имя управляющего',
value: formData.managerName,
},
{ field: 'email', label: 'Email', value: formData.email },
]
// Дополнительные поля в зависимости от типа кабинета
const additionalFields = []
if (
user?.organization?.type === 'FULFILLMENT' ||
user?.organization?.type === 'LOGIST' ||
user?.organization?.type === 'WHOLESALE' ||
user?.organization?.type === 'SELLER'
) {
// Финансовые данные - всегда обязательны для всех типов кабинетов
additionalFields.push(
{
field: 'bankName',
label: 'Название банка',
value: formData.bankName,
},
{ field: 'bik', label: 'БИК', value: formData.bik },
{
field: 'accountNumber',
label: 'Расчетный счет',
value: formData.accountNumber,
},
{
field: 'corrAccount',
label: 'Корр. счет',
value: formData.corrAccount,
},
)
}
const allRequiredFields = [...baseFields, ...additionalFields]
const filledRequiredFields = allRequiredFields.filter((field) => field.value && field.value.trim() !== '').length
// Подсчитываем бонусные баллы за автоматически заполненные поля
let autoFilledFields = 0
let totalAutoFields = 0
// Номер телефона пользователя для авторизации (не считаем в процентах заполненности)
// Телефон организации учитывается отдельно как обычное поле
// Данные организации из DaData (если есть ИНН)
if (formData.inn || user?.organization?.inn) {
totalAutoFields += 5 // ИНН + название + адрес + полное название + ОГРН
if (formData.inn || user?.organization?.inn) autoFilledFields += 1 // ИНН
if (formData.orgName || user?.organization?.name) autoFilledFields += 1 // Название
if (formData.address || user?.organization?.address) autoFilledFields += 1 // Адрес
if (formData.fullName || user?.organization?.fullName) autoFilledFields += 1 // Полное название
if (formData.ogrn || user?.organization?.ogrn) autoFilledFields += 1 // ОГРН
}
// Место регистрации
if (formData.registrationPlace || user?.organization?.registrationDate) {
autoFilledFields += 1
totalAutoFields += 1
}
const totalPossibleFields = allRequiredFields.length + totalAutoFields
const totalFilledFields = filledRequiredFields + autoFilledFields
const percentage = totalPossibleFields > 0 ? Math.round((totalFilledFields / totalPossibleFields) * 100) : 0
const missingFields = allRequiredFields
.filter((field) => !field.value || field.value.trim() === '')
.map((field) => field.label)
return { percentage, missingFields }
}
const profileStatus = checkProfileCompleteness()
const isIncomplete = profileStatus.percentage < 100
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !user?.id) return
setIsUploadingAvatar(true)
setSaveMessage(null)
try {
const avatarUrl = await S3Service.uploadAvatar(file, user.id)
// Сразу обновляем локальное состояние для мгновенного отображения
setLocalAvatarUrl(avatarUrl)
// Обновляем аватар пользователя через GraphQL
const result = await updateUserProfile({
variables: {
input: {
avatar: avatarUrl,
},
},
update: (cache, { data }: { data?: any }) => {
if (data?.updateUserProfile?.success) {
// Обновляем кеш Apollo Client
try {
const existingData: any = cache.readQuery({ query: GET_ME })
if (existingData?.me) {
cache.writeQuery({
query: GET_ME,
data: {
me: {
...existingData.me,
avatar: avatarUrl,
},
},
})
}
} catch {
// Игнорируем ошибки обновления кеша
}
}
},
})
if (result.data?.updateUserProfile?.success) {
setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен!' })
// Обновляем локальное состояние в useAuth для мгновенного отображения в сайдбаре
updateUser({ avatar: avatarUrl })
// Принудительно обновляем Apollo Client кеш
await apolloClient.refetchQueries({
include: [GET_ME],
})
// Очищаем input файла
if (event.target) {
event.target.value = ''
}
// Очищаем сообщение через 3 секунды
setTimeout(() => {
setSaveMessage(null)
}, 3000)
} else {
throw new Error(result.data?.updateUserProfile?.message || 'Failed to update avatar')
}
} catch (error) {
console.error('Error uploading avatar:', error)
// Сбрасываем локальное состояние при ошибке
setLocalAvatarUrl(null)
const errorMessage = error instanceof Error ? error.message : 'Ошибка при загрузке аватара'
setSaveMessage({ type: 'error', text: errorMessage })
// Очищаем сообщение об ошибке через 5 секунд
setTimeout(() => {
setSaveMessage(null)
}, 5000)
} finally {
setIsUploadingAvatar(false)
}
}
// Функции для валидации и масок
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
const formatPhoneInput = (value: string, isOptional: boolean = false) => {
// Убираем все нецифровые символы
const digitsOnly = value.replace(/\D/g, '')
// Если строка пустая
if (!digitsOnly) {
// Для необязательных полей возвращаем пустую строку
if (isOptional) return ''
// Для обязательных полей возвращаем +7
return '+7'
}
// Если пользователь ввел первую цифру не 7, добавляем 7 перед ней
let cleaned = digitsOnly
if (!cleaned.startsWith('7')) {
cleaned = '7' + cleaned
}
// Ограничиваем до 11 цифр (7 + 10 цифр номера)
cleaned = cleaned.slice(0, 11)
// Форматируем в зависимости от длины
if (cleaned.length <= 1) return isOptional && cleaned === '7' ? '' : '+7'
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
if (cleaned.length <= 11)
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
const handlePhoneInputChange = (
field: string,
value: string,
inputRef: React.RefObject<HTMLInputElement | null>,
isOptional: boolean = false,
) => {
const currentInput = inputRef?.current
const currentCursorPosition = currentInput?.selectionStart || 0
const currentValue = (formData[field as keyof typeof formData] as string) || ''
// Для необязательных полей разрешаем пустое значение
if (isOptional && value.length < 2) {
const formatted = formatPhoneInput(value, true)
setFormData((prev) => ({ ...prev, [field]: formatted }))
return
}
// Для обязательных полей если пользователь пытается удалить +7, предотвращаем это
if (!isOptional && value.length < 2) {
value = '+7'
}
const formatted = formatPhoneInput(value, isOptional)
setFormData((prev) => ({ ...prev, [field]: formatted }))
// Вычисляем новую позицию курсора
if (currentInput) {
setTimeout(() => {
let newCursorPosition = currentCursorPosition
// Если длина увеличилась (добавили цифру), передвигаем курсор
if (formatted.length > currentValue.length) {
newCursorPosition = currentCursorPosition + (formatted.length - currentValue.length)
}
// Если длина уменьшилась (удалили цифру), оставляем курсор на месте или сдвигаем немного
else if (formatted.length < currentValue.length) {
newCursorPosition = Math.min(currentCursorPosition, formatted.length)
}
// Не позволяем курсору находиться перед +7
newCursorPosition = Math.max(newCursorPosition, 2)
// Ограничиваем курсор длиной строки
newCursorPosition = Math.min(newCursorPosition, formatted.length)
currentInput.setSelectionRange(newCursorPosition, newCursorPosition)
}, 0)
}
}
const formatTelegram = (value: string) => {
// Убираем все символы кроме букв, цифр, _ и @
let cleaned = value.replace(/[^a-zA-Z0-9_@]/g, '')
// Убираем лишние символы @
cleaned = cleaned.replace(/@+/g, '@')
// Если есть символы после удаления @ и строка не начинается с @, добавляем @
if (cleaned && !cleaned.startsWith('@')) {
cleaned = '@' + cleaned
}
// Ограничиваем длину (максимум 32 символа для Telegram)
if (cleaned.length > 33) {
cleaned = cleaned.substring(0, 33)
}
return cleaned
}
const validateName = (name: string) => {
return /^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(name) && name.trim().length >= 2
}
const handleInputChange = (field: string, value: string) => {
let processedValue = value
// Применяем маски и валидации
switch (field) {
case 'orgPhone':
case 'whatsapp':
processedValue = formatPhoneInput(value)
break
case 'telegram':
processedValue = formatTelegram(value)
break
case 'email':
// Для email не применяем маску, только валидацию при потере фокуса
break
case 'managerName':
// Разрешаем только буквы, пробелы и дефисы
processedValue = value.replace(/[^а-яёА-ЯЁa-zA-Z\s-]/g, '')
break
}
setFormData((prev) => ({ ...prev, [field]: processedValue }))
}
// Функции для проверки ошибок
const getFieldError = (field: string, value: string) => {
if (!isEditing || !value.trim()) return null
switch (field) {
case 'email':
return !validateEmail(value) ? 'Неверный формат email' : null
case 'managerName':
return !validateName(value) ? 'Только буквы, пробелы и дефисы' : null
case 'orgPhone':
case 'whatsapp':
const cleaned = value.replace(/\D/g, '')
return cleaned.length !== 11 ? 'Неверный формат телефона' : null
case 'telegram':
// Проверяем что после @ есть минимум 5 символов
const usernameLength = value.startsWith('@') ? value.length - 1 : value.length
return usernameLength < 5 ? 'Минимум 5 символов после @' : null
case 'inn':
// Игнорируем автоматически сгенерированные ИНН селлеров
if (value.startsWith('SELLER_')) {
return null
}
const innCleaned = value.replace(/\D/g, '')
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
return 'ИНН должен содержать 10 или 12 цифр'
}
return null
case 'bankName':
return value.trim().length < 3 ? 'Минимум 3 символа' : null
case 'bik':
const bikCleaned = value.replace(/\D/g, '')
return bikCleaned.length !== 9 ? 'БИК должен содержать 9 цифр' : null
case 'accountNumber':
const accountCleaned = value.replace(/\D/g, '')
return accountCleaned.length !== 20 ? 'Расчетный счет должен содержать 20 цифр' : null
case 'corrAccount':
const corrCleaned = value.replace(/\D/g, '')
return corrCleaned.length !== 20 ? 'Корр. счет должен содержать 20 цифр' : null
default:
return null
}
}
// Проверка наличия изменений в форме
const hasFormChanges = () => {
if (!user?.organization) return false
const org = user.organization
// Извлекаем текущий телефон из organization.phones
let currentOrgPhone = '+7'
if (org.phones && Array.isArray(org.phones) && org.phones.length > 0) {
currentOrgPhone = org.phones[0].value || org.phones[0] || '+7'
}
// Извлекаем текущий email из organization.emails
let currentEmail = ''
if (org.emails && Array.isArray(org.emails) && org.emails.length > 0) {
currentEmail = org.emails[0].value || org.emails[0] || ''
}
// Извлекаем дополнительные данные из managementPost
let customContacts: any = {}
try {
if (org.managementPost && typeof org.managementPost === 'string') {
// Проверяем, что строка начинается с { или [, иначе это не JSON
if (org.managementPost.trim().startsWith('{') || org.managementPost.trim().startsWith('[')) {
customContacts = JSON.parse(org.managementPost)
}
}
} catch {
// ignore parse errors
}
// Нормализуем значения для сравнения
const normalizeValue = (value: string | null | undefined) => value || ''
const normalizeMarketValue = (value: string | null | undefined) => value || 'none'
// Проверяем изменения в полях
const changes = [
normalizeValue(formData.orgPhone) !== normalizeValue(currentOrgPhone),
normalizeValue(formData.managerName) !== normalizeValue(user?.managerName),
normalizeValue(formData.telegram) !== normalizeValue(customContacts?.telegram),
normalizeValue(formData.whatsapp) !== normalizeValue(customContacts?.whatsapp),
normalizeValue(formData.email) !== normalizeValue(currentEmail),
normalizeMarketValue(formData.market) !== normalizeMarketValue((org as any).market),
normalizeValue(formData.bankName) !== normalizeValue(customContacts?.bankDetails?.bankName),
normalizeValue(formData.bik) !== normalizeValue(customContacts?.bankDetails?.bik),
normalizeValue(formData.accountNumber) !== normalizeValue(customContacts?.bankDetails?.accountNumber),
normalizeValue(formData.corrAccount) !== normalizeValue(customContacts?.bankDetails?.corrAccount),
]
const hasChanges = changes.some((changed) => changed)
return hasChanges
}
// Проверка наличия ошибок валидации
const hasValidationErrors = () => {
const fields = [
'orgPhone',
'managerName',
'telegram',
'whatsapp',
'email',
'inn',
'bankName',
'bik',
'accountNumber',
'corrAccount',
]
// Проверяем ошибки валидации только в заполненных полях
const hasErrors = fields.some((field) => {
const value = formData[field as keyof typeof formData]
// Проверяем ошибки только для заполненных полей
if (!value || !value.trim()) return false
const error = getFieldError(field, value)
return error !== null
})
// Убираем проверку обязательных полей - пользователь может заполнять постепенно
return hasErrors
}
const handleSave = async () => {
// Сброс предыдущих сообщений
setSaveMessage(null)
try {
// Проверяем, изменился ли ИНН и нужно ли обновить данные организации
const currentInn = formData.inn || user?.organization?.inn || ''
const originalInn = user?.organization?.inn || ''
const innCleaned = currentInn.replace(/\D/g, '')
const originalInnCleaned = originalInn.replace(/\D/g, '')
// Если ИНН изменился и валиден, сначала обновляем данные организации
if (innCleaned !== originalInnCleaned && (innCleaned.length === 10 || innCleaned.length === 12)) {
setSaveMessage({
type: 'success',
text: 'Обновляем данные организации...',
})
const orgResult = await updateOrganizationByInn({
variables: { inn: innCleaned },
})
if (!orgResult.data?.updateOrganizationByInn?.success) {
setSaveMessage({
type: 'error',
text: orgResult.data?.updateOrganizationByInn?.message || 'Ошибка при обновлении данных организации',
})
return
}
setSaveMessage({
type: 'success',
text: 'Данные организации обновлены. Сохраняем профиль...',
})
}
// Подготавливаем только заполненные поля для отправки
const inputData: {
orgPhone?: string
managerName?: string
telegram?: string
whatsapp?: string
email?: string
bankName?: string
bik?: string
accountNumber?: string
corrAccount?: string
market?: string
} = {}
// orgName больше не редактируется - устанавливается только при регистрации
if (formData.orgPhone?.trim()) inputData.orgPhone = formData.orgPhone.trim()
if (formData.managerName?.trim()) inputData.managerName = formData.managerName.trim()
if (formData.telegram?.trim()) inputData.telegram = formData.telegram.trim()
if (formData.whatsapp?.trim()) inputData.whatsapp = formData.whatsapp.trim()
if (formData.email?.trim()) inputData.email = formData.email.trim()
if (formData.bankName?.trim()) inputData.bankName = formData.bankName.trim()
if (formData.bik?.trim()) inputData.bik = formData.bik.trim()
if (formData.accountNumber?.trim()) inputData.accountNumber = formData.accountNumber.trim()
if (formData.corrAccount?.trim()) inputData.corrAccount = formData.corrAccount.trim()
if (formData.market) inputData.market = formData.market
const result = await updateUserProfile({
variables: {
input: inputData,
},
})
if (result.data?.updateUserProfile?.success) {
setSaveMessage({
type: 'success',
text: 'Профиль успешно сохранен! Обновляем страницу...',
})
// Простое обновление страницы после успешного сохранения
setTimeout(() => {
window.location.reload()
}, 1000)
} else {
setSaveMessage({
type: 'error',
text: result.data?.updateUserProfile?.message || 'Ошибка при сохранении профиля',
})
}
} catch (error) {
console.error('Error saving profile:', error)
setSaveMessage({ type: 'error', text: 'Ошибка при сохранении профиля' })
}
}
const formatDate = (dateString?: string) => {
if (!dateString) return ''
try {
let date: Date
// Проверяем, является ли строка числом (Unix timestamp)
if (/^\d+$/.test(dateString)) {
// Если это Unix timestamp в миллисекундах
const timestamp = parseInt(dateString, 10)
date = new Date(timestamp)
} else {
// Обычная строка даты
date = new Date(dateString)
}
if (isNaN(date.getTime())) {
return 'Неверная дата'
}
return date.toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
} catch {
return 'Ошибка даты'
}
}
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
<div className="h-full w-full flex flex-col">
{/* Сообщения о сохранении */}
{saveMessage && (
<Alert
className={`mb-4 ${
saveMessage.type === 'success' ? 'border-green-500 bg-green-500/10' : 'border-red-500 bg-red-500/10'
}`}
>
<AlertDescription className={saveMessage.type === 'success' ? 'text-green-400' : 'text-red-400'}>
{saveMessage.text}
</AlertDescription>
</Alert>
)}
{/* Основной контент с вкладками - заполняет оставшееся пространство */}
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="profile" className="h-full flex flex-col">
<TabsList
className={`grid w-full glass-card mb-4 flex-shrink-0 ${
user?.organization?.type === 'SELLER'
? 'grid-cols-4'
: user?.organization?.type === 'FULFILLMENT' ||
user?.organization?.type === 'LOGIST' ||
user?.organization?.type === 'WHOLESALE'
? 'grid-cols-4'
: 'grid-cols-3'
}`}
>
<TabsTrigger value="profile" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<User className="h-4 w-4 mr-2" />
Профиль
</TabsTrigger>
<TabsTrigger value="organization" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<Building2 className="h-4 w-4 mr-2" />
Организация
</TabsTrigger>
{(user?.organization?.type === 'FULFILLMENT' ||
user?.organization?.type === 'LOGIST' ||
user?.organization?.type === 'WHOLESALE' ||
user?.organization?.type === 'SELLER') && (
<TabsTrigger value="financial" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<CreditCard className="h-4 w-4 mr-2" />
Финансы
</TabsTrigger>
)}
{user?.organization?.type === 'SELLER' && (
<TabsTrigger value="api" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<Key className="h-4 w-4 mr-2" />
API
</TabsTrigger>
)}
{user?.organization?.type !== 'SELLER' && (
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<Settings className="h-4 w-4 mr-2" />
Инструменты
</TabsTrigger>
)}
</TabsList>
{/* Профиль пользователя */}
<TabsContent value="profile" className="flex-1 overflow-hidden">
<Card className="glass-card p-6 h-full overflow-auto">
{/* Заголовок вкладки с прогрессом и кнопками */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
<div className="flex items-center gap-4">
<User className="h-6 w-6 text-purple-400" />
<div>
<h2 className="text-lg font-semibold text-white">Профиль пользователя</h2>
<p className="text-white/70 text-sm">Личная информация и контактные данные</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Компактный индикатор прогресса */}
<div className="flex items-center gap-2 mr-2">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
</div>
<div className="hidden sm:block text-xs text-white/70">
{isIncomplete ? (
<>Заполнено {profileStatus.percentage}% профиля</>
) : (
<>Профиль полностью заполнен</>
)}
</div>
</div>
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
Отмена
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving || !hasFormChanges()}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving || !hasFormChanges()
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
) : (
<Button
size="sm"
onClick={() => setIsEditing(true)}
className="glass-button text-white cursor-pointer"
>
<Edit3 className="h-4 w-4 mr-2" />
Редактировать
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4 mb-6">
<div className="relative">
<Avatar className="h-16 w-16">
{localAvatarUrl || user?.avatar ? (
<Image
src={localAvatarUrl || user?.avatar || ''}
alt="Аватар"
width={64}
height={64}
className="w-full h-full object-cover rounded-full"
/>
) : (
<AvatarFallback className="bg-purple-500 text-white text-lg">{getInitials()}</AvatarFallback>
)}
</Avatar>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="avatar-upload" className="cursor-pointer">
<div className="w-6 h-6 bg-purple-600 rounded-full flex items-center justify-center hover:bg-purple-700 transition-colors">
{isUploadingAvatar ? (
<RefreshCw className="h-3 w-3 text-white animate-spin" />
) : (
<Camera className="h-3 w-3 text-white" />
)}
</div>
</label>
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
disabled={isUploadingAvatar}
/>
</div>
</div>
<div className="flex-1">
<p className="text-white font-medium text-lg">
{user?.organization?.name || user?.organization?.fullName || 'Пользователь'}
</p>
<Badge variant="outline" className="bg-white/10 text-white border-white/20 mt-1">
{getCabinetTypeName()}
</Badge>
<p className="text-white/60 text-sm mt-2">
Авторизован по номеру: {formatPhone(user?.phone || '')}
</p>
{user?.createdAt && (
<p className="text-white/50 text-xs mt-1 flex items-center gap-1">
<Calendar className="h-3 w-3" />
Дата регистрации: {formatDate(user.createdAt)}
</p>
)}
</div>
<CheckCircle className="h-5 w-5 text-green-400 ml-auto" />
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
<Input
ref={phoneInputRef}
value={formData.orgPhone || ''}
onChange={(e) => handlePhoneInputChange('orgPhone', e.target.value, phoneInputRef)}
onKeyDown={(e) => {
// Предотвращаем удаление +7
if (
(e.key === 'Backspace' || e.key === 'Delete') &&
(phoneInputRef.current?.selectionStart || 0) <= 2
) {
e.preventDefault()
}
}}
placeholder="+7 (999) 999-99-99"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('orgPhone', formData.orgPhone) ? 'border-red-400' : ''
}`}
/>
{getFieldError('orgPhone', formData.orgPhone) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('orgPhone', formData.orgPhone)}
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Имя управляющего</Label>
<Input
value={formData.managerName || ''}
onChange={(e) => handleInputChange('managerName', e.target.value)}
placeholder="Иван Иванов"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('managerName', formData.managerName) ? 'border-red-400' : ''
}`}
/>
{getFieldError('managerName', formData.managerName) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('managerName', formData.managerName)}
</p>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-blue-400" />
Telegram
</Label>
<Input
value={formData.telegram || ''}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('telegram', formData.telegram) ? 'border-red-400' : ''
}`}
/>
{getFieldError('telegram', formData.telegram) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('telegram', formData.telegram)}
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 flex items-center gap-2">
<Phone className="h-4 w-4 text-green-400" />
WhatsApp
</Label>
<Input
ref={whatsappInputRef}
value={formData.whatsapp || ''}
onChange={(e) => handlePhoneInputChange('whatsapp', e.target.value, whatsappInputRef, true)}
onKeyDown={(_e) => {
// Для WhatsApp разрешаем полное удаление (поле необязательное)
// Никаких ограничений на удаление
}}
placeholder="Необязательно"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('whatsapp', formData.whatsapp) ? 'border-red-400' : ''
}`}
/>
{getFieldError('whatsapp', formData.whatsapp) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('whatsapp', formData.whatsapp)}
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 flex items-center gap-2">
<Mail className="h-4 w-4 text-red-400" />
Email
</Label>
<Input
type="email"
value={formData.email || ''}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="example@company.com"
readOnly={!isEditing}
className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${
getFieldError('email', formData.email) ? 'border-red-400' : ''
}`}
/>
{getFieldError('email', formData.email) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('email', formData.email)}
</p>
)}
</div>
</div>
</div>
</Card>
</TabsContent>
{/* Организация и юридические данные */}
<TabsContent value="organization" className="flex-1 overflow-hidden">
<Card className="glass-card p-6 h-full overflow-hidden">
{/* Заголовок вкладки с кнопками */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
<div className="flex items-center gap-4">
<Building2 className="h-6 w-6 text-blue-400" />
<div>
<h2 className="text-lg font-semibold text-white">Данные организации</h2>
<p className="text-white/70 text-sm">Юридическая информация и реквизиты</p>
</div>
</div>
<div className="flex items-center gap-2">
{(formData.inn || user?.organization?.inn) && (
<div className="flex items-center gap-2 mr-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-green-400 text-sm">Проверено</span>
</div>
)}
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
Отмена
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving || !hasFormChanges()}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving || !hasFormChanges()
? 'opacity-50 cursor-not-allowed'
: ''
}`}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
) : (
<Button
size="sm"
onClick={() => setIsEditing(true)}
className="glass-button text-white cursor-pointer"
>
<Edit3 className="h-4 w-4 mr-2" />
Редактировать
</Button>
)}
</div>
</div>
{/* Общая подпись про реестр */}
<div className="mb-6 p-3 bg-blue-500/10 rounded-lg border border-blue-500/20">
<p className="text-blue-300 text-sm flex items-center gap-2">
<RefreshCw className="h-4 w-4" />
При сохранении с измененным ИНН мы автоматически обновляем все остальные данные из федерального
реестра
</p>
</div>
<div className="space-y-4">
{/* Названия */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
{user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
</Label>
<Input
value={formData.orgName || user?.organization?.name || ''}
onChange={(e) => handleInputChange('orgName', e.target.value)}
placeholder={
user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'
}
readOnly={true}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
{user?.organization?.type === 'SELLER' ? (
<p className="text-white/50 text-xs mt-1">
Название устанавливается при регистрации кабинета и не может быть изменено.
</p>
) : (
<p className="text-white/50 text-xs mt-1">
Автоматически заполняется из федерального реестра при указании ИНН.
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Полное название</Label>
<Input
value={formData.fullName || user?.organization?.fullName || ''}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
</div>
{/* Адреса */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 flex items-center gap-2">
<MapPin className="h-4 w-4" />
Адрес
</Label>
<Input
value={formData.address || user?.organization?.address || ''}
onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="г. Москва, ул. Примерная, д. 1"
readOnly={!isEditing || !!(formData.address || user?.organization?.address)}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Полный юридический адрес</Label>
<Input
value={user?.organization?.addressFull || ''}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
</div>
{/* ИНН, ОГРН, КПП */}
<div className="grid grid-cols-3 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 flex items-center gap-2">
ИНН
{isUpdatingOrganization && <RefreshCw className="h-3 w-3 animate-spin text-blue-400" />}
</Label>
<Input
value={formData.inn || user?.organization?.inn || ''}
onChange={(e) => {
handleInputChange('inn', e.target.value)
}}
placeholder="Введите ИНН организации"
readOnly={!isEditing}
disabled={isUpdatingOrganization}
className={`glass-input text-white placeholder:text-white/40 h-10 ${
!isEditing ? 'read-only:opacity-70' : ''
} ${
getFieldError('inn', formData.inn) ? 'border-red-400' : ''
} ${isUpdatingOrganization ? 'opacity-50' : ''}`}
/>
{getFieldError('inn', formData.inn) && (
<p className="text-red-400 text-xs mt-1">{getFieldError('inn', formData.inn)}</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">ОГРН</Label>
<Input
value={formData.ogrn || user?.organization?.ogrn || ''}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">КПП</Label>
<Input
value={user?.organization?.kpp || ''}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
</div>
{/* Руководитель и статус */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Руководитель организации</Label>
<Input
value={user?.organization?.managementName || 'Данные не указаны в реестре'}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
placeholder="Данные отсутствуют в федеральном реестре"
/>
<p className="text-white/50 text-xs mt-1">
{user?.organization?.managementName
? 'Данные из федерального реестра'
: 'Автоматически заполняется из федерального реестра при указании ИНН'}
</p>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
<Input
value={
user?.organization?.status === 'ACTIVE'
? 'Действующая'
: user?.organization?.status || 'Статус не указан'
}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
</div>
{/* Дата регистрации */}
{user?.organization?.registrationDate && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Дата регистрации
</Label>
<Input
value={formatDate(user.organization.registrationDate)}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
</div>
)}
{/* Настройка рынка для поставщиков */}
{user?.organization?.type === 'WHOLESALE' && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 flex items-center gap-2">
🏪 Физический рынок
</Label>
{isEditing ? (
<Select
value={formData.market || 'none'}
onValueChange={(value) => handleInputChange('market', value)}
>
<SelectTrigger className="glass-input text-white h-10 text-sm">
<SelectValue placeholder="Выберите рынок" />
</SelectTrigger>
<SelectContent className="glass-card">
<SelectItem value="none">Не указан</SelectItem>
<SelectItem value="sadovod" className="text-white">
Садовод
</SelectItem>
<SelectItem value="tyak-moscow" className="text-white">
ТЯК Москва
</SelectItem>
</SelectContent>
</Select>
) : (
<Input
value={
formData.market && formData.market !== 'none'
? formData.market === 'sadovod'
? 'Садовод'
: formData.market === 'tyak-moscow'
? 'ТЯК Москва'
: formData.market
: 'Не указан'
}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
)}
<p className="text-white/50 text-xs mt-1">
Физический рынок, где работает поставщик. Товары наследуют рынок от организации.
</p>
</div>
</div>
)}
</div>
</Card>
</TabsContent>
{/* Финансовые данные */}
{(user?.organization?.type === 'FULFILLMENT' ||
user?.organization?.type === 'LOGIST' ||
user?.organization?.type === 'WHOLESALE' ||
user?.organization?.type === 'SELLER') && (
<TabsContent value="financial" className="flex-1 overflow-hidden">
<Card className="glass-card p-6 h-full overflow-auto">
{/* Заголовок вкладки с кнопками */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
<div className="flex items-center gap-4">
<CreditCard className="h-6 w-6 text-red-400" />
<div>
<h2 className="text-lg font-semibold text-white">Финансовые данные</h2>
<p className="text-white/70 text-sm">Банковские реквизиты для расчетов</p>
</div>
</div>
<div className="flex items-center gap-2">
{formData.bankName && formData.bik && formData.accountNumber && formData.corrAccount && (
<div className="flex items-center gap-2 mr-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-green-400 text-sm">Заполнено</span>
</div>
)}
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
Отмена
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving || !hasFormChanges()}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
) : (
<Button
size="sm"
onClick={() => setIsEditing(true)}
className="glass-button text-white cursor-pointer"
>
<Edit3 className="h-4 w-4 mr-2" />
Редактировать
</Button>
)}
</div>
</div>
<div className="space-y-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Название банка</Label>
<Input
value={formData.bankName || ''}
onChange={(e) => handleInputChange('bankName', e.target.value)}
placeholder="ПАО Сбербанк"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">БИК</Label>
<Input
value={formData.bik || ''}
onChange={(e) => handleInputChange('bik', e.target.value)}
placeholder="044525225"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Корр. счет</Label>
<Input
value={formData.corrAccount || ''}
onChange={(e) => handleInputChange('corrAccount', e.target.value)}
placeholder="30101810400000000225"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
</div>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Расчетный счет</Label>
<Input
value={formData.accountNumber || ''}
onChange={(e) => handleInputChange('accountNumber', e.target.value)}
placeholder="40702810123456789012"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
</div>
</div>
</Card>
</TabsContent>
)}
{/* API ключи для селлера */}
{user?.organization?.type === 'SELLER' && (
<TabsContent value="api" className="flex-1 overflow-hidden">
<Card className="glass-card p-6 h-full overflow-auto">
{/* Заголовок вкладки с кнопками */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
<div className="flex items-center gap-4">
<Key className="h-6 w-6 text-green-400" />
<div>
<h2 className="text-lg font-semibold text-white">API ключи маркетплейсов</h2>
<p className="text-white/70 text-sm">Интеграция с торговыми площадками</p>
</div>
</div>
<div className="flex items-center gap-2">
{user?.organization?.apiKeys?.length > 0 && (
<div className="flex items-center gap-2 mr-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-green-400 text-sm">Настроено</span>
</div>
)}
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
Отмена
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={hasValidationErrors() || isSaving || !hasFormChanges()}
className={`glass-button text-white cursor-pointer ${
hasValidationErrors() || isSaving ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Сохранение...' : 'Сохранить'}
</Button>
</>
) : (
<Button
size="sm"
onClick={() => setIsEditing(true)}
className="glass-button text-white cursor-pointer"
>
<Edit3 className="h-4 w-4 mr-2" />
Редактировать
</Button>
)}
</div>
</div>
<div className="space-y-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
<Input
value={
isEditing
? formData.wildberriesApiKey || ''
: user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES')
? '••••••••••••••••••••'
: ''
}
onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)}
placeholder="Введите API ключ Wildberries"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
autoComplete="off"
spellCheck="false"
/>
{(user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES') ||
(formData.wildberriesApiKey && isEditing)) && (
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
<Input
value={
isEditing
? formData.ozonApiKey || ''
: user?.organization?.apiKeys?.find((key) => key.marketplace === 'OZON')
? '••••••••••••••••••••'
: ''
}
onChange={(e) => handleInputChange('ozonApiKey', e.target.value)}
placeholder="Введите API ключ Ozon"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
autoComplete="off"
spellCheck="false"
/>
{(user?.organization?.apiKeys?.find((key) => key.marketplace === 'OZON') ||
(formData.ozonApiKey && isEditing)) && (
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
</p>
)}
</div>
</div>
</Card>
</TabsContent>
)}
{/* Инструменты */}
<TabsContent value="tools" className="flex-1 overflow-hidden">
<Card className="glass-card p-6 h-full overflow-auto">
{/* Заголовок вкладки */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-white/10">
<div className="flex items-center gap-4">
<Settings className="h-6 w-6 text-green-400" />
<div>
<h2 className="text-lg font-semibold text-white">Инструменты</h2>
<p className="text-white/70 text-sm">Дополнительные возможности для бизнеса</p>
</div>
</div>
</div>
<div className="space-y-6">
<div className="text-center py-12">
<Settings className="h-16 w-16 text-white/20 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">Инструменты в разработке</h3>
<p className="text-white/60 text-sm max-w-md mx-auto">
Здесь будут размещены полезные бизнес-инструменты: калькуляторы, аналитика, планировщики и
автоматизация процессов.
</p>
<div className="mt-6">
<Badge variant="outline" className="bg-blue-500/20 text-blue-300 border-blue-500/30">
Скоро появится
</Badge>
</div>
</div>
</div>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</main>
</div>
)
}

View File

@ -5,6 +5,8 @@ import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { useQuery } from '@apollo/client'
import { GET_ME } from '@/graphql/queries'
// Рендерим сайдбар только на клиенте, отключаем SSR, чтобы избежать гидратационных расхождений
const Sidebar = dynamic(() => import('@/components/dashboard/sidebar').then((m) => m.Sidebar), {
@ -16,6 +18,14 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth()
const [mounted, setMounted] = useState(false)
// Используем GET_ME напрямую чтобы получить актуальные данные
const { data: meData } = useQuery(GET_ME, {
skip: !isAuthenticated,
fetchPolicy: 'cache-first', // Используем кеш Apollo
})
const user = meData?.me || null
useEffect(() => {
setMounted(true)
}, [])
@ -24,11 +34,40 @@ export function AppShell({ children }: { children: React.ReactNode }) {
pathname === '/' || pathname?.startsWith('/login') || pathname?.startsWith('/register')
// До маунта всегда скрываем сайдбар, чтобы избежать гидратационного рассинхрона
// ВРЕМЕННО: показываем сайдбар если авторизован, даже без данных пользователя
const hideSidebar = !mounted || hideByRoute || isLoading || !isAuthenticated
// Отладочные логи (включены в development)
if (process.env.NODE_ENV === 'development') {
console.warn('AppShell state:', {
mounted,
hideByRoute,
isLoading,
isAuthenticated,
hasUser: !!user,
hasDirectMeData: !!meData?.me,
hideSidebar,
pathname,
userPhone: user?.phone,
organizationType: user?.organization?.type
})
// Дополнительная диагностика
if (!hideSidebar) {
console.warn('🟢 AppShell: RENDERING SIDEBAR with user:', user)
} else {
console.warn('🔴 AppShell: NOT RENDERING SIDEBAR because:', {
notMounted: !mounted,
hideByRoute,
isLoading,
notAuthenticated: !isAuthenticated
})
}
}
return (
<>
{!hideSidebar && <Sidebar isRootInstance />}
{!hideSidebar && <Sidebar isRootInstance user={user} />}
<div className="flex-1 min-w-0 overflow-hidden">{children}</div>
</>
)

View File

@ -63,25 +63,18 @@ export const AdvertisingTab = memo<CampaignStatsProps>(function AdvertisingTab({
{/* Блок ошибок */}
<ErrorDisplayBlock error={error || undefined} />
{/* Селектор кампаний - пока заглушка */}
<div className="bg-muted/10 p-4 rounded-lg border-2 border-dashed border-muted">
<p className="text-sm text-muted-foreground text-center">
🚧 Селектор кампаний будет добавлен в следующих компонентах
</p>
</div>
{/* График расходов - пока заглушка */}
<div className="bg-muted/10 p-4 rounded-lg border-2 border-dashed border-muted">
<p className="text-sm text-muted-foreground text-center">
📊 График расходов будет добавлен в следующих компонентах
</p>
</div>
{/* Таблица данных - пока заглушка */}
<div className="bg-muted/10 p-4 rounded-lg border-2 border-dashed border-muted">
<p className="text-sm text-muted-foreground text-center">
📋 Таблица статистики будет добавлена в следующих компонентах
</p>
{/* Статистика рекламы - в разработке */}
<div className="bg-card/50 p-8 rounded-lg border">
<div className="text-center space-y-4">
<div className="w-16 h-16 mx-auto bg-muted rounded-full flex items-center justify-center">
<span className="text-2xl">📈</span>
</div>
<h3 className="text-lg font-medium">Статистика рекламы</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Раздел рекламной статистики находится в разработке.
Здесь будут отображаться данные о кампаниях, расходах и эффективности рекламы.
</p>
</div>
</div>
{/* Состояние загрузки/пустых данных */}
@ -90,19 +83,21 @@ export const AdvertisingTab = memo<CampaignStatsProps>(function AdvertisingTab({
hasData={hasData}
/>
{/* Отладочная информация */}
<div className="text-xs text-muted-foreground p-2 bg-muted/5 rounded">
<div>Период: {selectedPeriod}</div>
<div>Кастомные даты: {useCustomDates ? 'Да' : 'Нет'}</div>
<div>Данных: {dailyData.length} дней</div>
<div>Фотографий: {productPhotos.size}</div>
<div>UI состояние: {JSON.stringify({
expandedDays: uiState.expandedDays.size,
expandedProducts: uiState.expandedProducts.size,
showWbAds: uiState.showWbAds,
showExternalAds: uiState.showExternalAds,
})}</div>
</div>
{/* Временно скрываем отладочную информацию */}
{process.env.NODE_ENV === 'development' && false && (
<div className="text-xs text-muted-foreground p-2 bg-muted/5 rounded">
<div>Период: {selectedPeriod}</div>
<div>Кастомные даты: {useCustomDates ? 'Да' : 'Нет'}</div>
<div>Данных: {dailyData.length} дней</div>
<div>Фотографий: {productPhotos.size}</div>
<div>UI состояние: {JSON.stringify({
expandedDays: uiState.expandedDays.size,
expandedProducts: uiState.expandedProducts.size,
showWbAds: uiState.showWbAds,
showExternalAds: uiState.showExternalAds,
})}</div>
</div>
)}
</div>
)
})

View File

@ -126,7 +126,7 @@ const SellerStatisticsDashboard = React.memo(() => {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
{/* Sidebar рендерится через AppShell, не дублируем здесь */}
<main className={`flex-1 ${getSidebarMargin()} px-4 py-4 overflow-hidden transition-all duration-300`}>
<div className="h-full w-full flex flex-col">
{/* Убираем ограничение по ширине для полного использования экрана */}

View File

@ -26,13 +26,17 @@ export const userManagementResolvers: DomainResolvers = {
Query: {
// Получить текущего пользователя
me: async (_: unknown, __: unknown, context: Context) => {
console.warn('🔍 USER-MANAGEMENT ME QUERY:', { hasUser: !!context.user, userId: context.user?.id })
if (!context.user) {
console.warn('❌ USER-MANAGEMENT ME: No user in context')
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' },
})
}
return await prisma.user.findUnique({
console.warn('🔍 USER-MANAGEMENT ME: Fetching user from DB...')
const user = await prisma.user.findUnique({
where: { id: context.user.id },
include: {
organization: {
@ -42,6 +46,15 @@ export const userManagementResolvers: DomainResolvers = {
},
},
})
console.warn('✅ USER-MANAGEMENT ME RESULT:', {
found: !!user,
hasOrganization: !!user?.organization,
organizationType: user?.organization?.type,
phone: user?.phone
})
return user
},
},

View File

@ -166,7 +166,46 @@ export const wildberriesResolvers: DomainResolvers = {
}
const wbService = new WildberriesService(apiKey.apiKey)
const statistics = await wbService.getStatistics(args.startDate, args.endDate)
console.log('🔑 API Key length:', apiKey.apiKey?.length)
console.log('📅 Date params:', { startDate: args.startDate, endDate: args.endDate, period: args.period })
// Если нет конкретных дат, генерируем их на основе периода
let startDate = args.startDate
let endDate = args.endDate
if (!startDate || !endDate) {
const now = new Date()
const today = now.toISOString().split('T')[0] // YYYY-MM-DD
switch (args.period) {
case 'week':
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
startDate = weekAgo.toISOString().split('T')[0]
endDate = today
break
case 'month':
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
startDate = monthAgo.toISOString().split('T')[0]
endDate = today
break
default:
// По умолчанию неделя
const defaultWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
startDate = defaultWeekAgo.toISOString().split('T')[0]
endDate = today
}
}
console.log('📅 Calculated dates:', { startDate, endDate })
const statistics = await wbService.getStatistics(startDate, endDate)
console.log('📊 Statistics result:', {
type: typeof statistics,
isArray: Array.isArray(statistics),
length: statistics?.length,
firstItem: statistics?.[0] ? Object.keys(statistics[0]) : null
})
console.log('✅ GET_WILDBERRIES_STATISTICS DOMAIN SUCCESS')

View File

@ -1,5 +1,5 @@
import { useMutation } from '@apollo/client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import {
SEND_SMS_CODE,
@ -172,7 +172,7 @@ export const useAuth = (): UseAuthReturn => {
const [registerOrganizationMutation] = useMutation(REGISTER_ORGANIZATION) // 🚀 Новая мутация
// Проверка авторизации при инициализации
const checkAuth = async () => {
const checkAuth = useCallback(async () => {
if (isCheckingAuth) {
console.warn('useAuth - checkAuth already in progress, skipping')
return
@ -198,13 +198,17 @@ export const useAuth = (): UseAuthReturn => {
fetchPolicy: 'network-only', // Всегда делаем свежий запрос
})
console.warn('useAuth - GET_ME response:', !!data?.me)
console.warn('useAuth - GET_ME response:', { data, me: data?.me })
if (data?.me) {
console.warn('useAuth - Setting user:', data.me)
setUser(data.me)
setIsAuthenticated(true)
setUserData(data.me)
console.warn('useAuth - User authenticated:', data.me.phone)
// Форсируем обновление состояния
console.warn('🔄 useAuth - State updated, user is now:', data.me)
} else {
console.warn('useAuth - No user data in response')
setIsAuthenticated(false)
setUser(null)
}
@ -223,29 +227,18 @@ export const useAuth = (): UseAuthReturn => {
} finally {
setIsCheckingAuth(false)
}
}
}, [])
// Проверяем авторизацию при загрузке компонента только если нет данных пользователя
// Убираем автоматический checkAuth из useEffect - он будет вызываться только из AuthGuard
useEffect(() => {
const token = getAuthToken()
console.warn(
'useAuth - useEffect init, token exists:',
!!token,
'user exists:',
!!user,
'isChecking:',
isCheckingAuth,
)
if (token && !user && !isCheckingAuth) {
console.warn('useAuth - Running checkAuth because token exists but no user data')
checkAuth()
} else if (!token) {
console.warn('useAuth - No token, setting unauthenticated state')
// Только устанавливаем состояние без токена
if (!token) {
setIsAuthenticated(false)
setUser(null)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}, []) // Только один раз при монтировании
const sendSmsCode = async (phone: string) => {
try {

View File

@ -395,6 +395,13 @@ class WildberriesService {
? { Authorization: `Bearer ${this.apiKey}` } // Marketplace и Content API используют Bearer
: { Authorization: this.apiKey } // Statistics и Advert API используют прямой токен
console.log('🌐 WB API REQUEST:', {
url,
method: options.method || 'GET',
hasAuth: !!authHeader.Authorization,
authType: url.includes('marketplace-api') || url.includes('content-api') ? 'Bearer' : 'Direct'
})
// Добавляем AbortController для timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 секунд timeout
@ -413,6 +420,12 @@ class WildberriesService {
clearTimeout(timeoutId) // Очищаем timeout при успешном ответе
if (!response.ok) {
console.error('🚨 WB API REQUEST FAILED:', {
url,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(Object.entries(authHeader))
})
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}