Добавлены новые зависимости, обновлены стили и улучшена структура проекта. Обновлен README с описанием функционала и технологий. Реализована анимация и адаптивный дизайн. Настроена авторизация с использованием Apollo Client.

This commit is contained in:
Bivekich
2025-07-16 18:00:41 +03:00
parent d260749bc9
commit 823ef9a28c
69 changed files with 15539 additions and 210 deletions

View File

@ -0,0 +1,109 @@
"use client"
import { useAuth } from '@/hooks/useAuth'
import { Card } from '@/components/ui/card'
import { Building2, Phone } from 'lucide-react'
import { Sidebar } from './sidebar'
export function DashboardHome() {
const { user } = useAuth()
const getOrganizationName = () => {
if (user?.organization?.name) {
return user.organization.name
}
if (user?.organization?.fullName) {
return user.organization.fullName
}
return 'Вашей организации'
}
const getCabinetType = () => {
if (!user?.organization?.type) return 'кабинета'
switch (user.organization.type) {
case 'FULFILLMENT':
return 'фулфилмент кабинета'
case 'SELLER':
return 'селлер кабинета'
case 'LOGIST':
return 'логистического кабинета'
case 'WHOLESALE':
return 'оптового кабинета'
default:
return 'кабинета'
}
}
return (
<div className="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-64">
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">
Добро пожаловать!
</h1>
<p className="text-white/80">
Главная панель управления {getCabinetType()}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Информация об организации */}
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3 mb-4">
<Building2 className="h-8 w-8 text-purple-400" />
<h3 className="text-xl font-semibold text-white">Организация</h3>
</div>
<div className="space-y-2">
<p className="text-white font-medium">
{getOrganizationName()}
</p>
{user?.organization?.inn && (
<p className="text-white/60 text-sm">
ИНН: {user.organization.inn}
</p>
)}
</div>
</Card>
{/* Контактная информация */}
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3 mb-4">
<Phone className="h-8 w-8 text-green-400" />
<h3 className="text-xl font-semibold text-white">Контакты</h3>
</div>
<div className="space-y-2">
<p className="text-white font-medium">
+{user?.phone}
</p>
<p className="text-white/60 text-sm">
Основной номер
</p>
</div>
</Card>
{/* Статистика или дополнительная информация */}
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3 mb-4">
<div className="h-8 w-8 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">SF</span>
</div>
<h3 className="text-xl font-semibold text-white">SferaV</h3>
</div>
<div className="space-y-2">
<p className="text-white font-medium">
Система управления бизнесом
</p>
<p className="text-white/60 text-sm">
Версия 1.0
</p>
</div>
</Card>
</div>
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,31 @@
"use client"
import { useState } from 'react'
import { Sidebar } from './sidebar'
import { UserSettings } from './user-settings'
import { DashboardHome } from './dashboard-home'
export type DashboardSection = 'home' | 'settings'
export function Dashboard() {
const [activeSection, setActiveSection] = useState<DashboardSection>('home')
const renderContent = () => {
switch (activeSection) {
case 'settings':
return <UserSettings />
case 'home':
default:
return <DashboardHome />
}
}
return (
<div className="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-64">
{renderContent()}
</main>
</div>
)
}

View File

@ -0,0 +1,141 @@
"use client"
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useRouter, usePathname } from 'next/navigation'
import {
Settings,
LogOut,
Building2,
Store
} from 'lucide-react'
export function Sidebar() {
const { user, logout } = useAuth()
const router = useRouter()
const pathname = usePathname()
const getInitials = () => {
const orgName = getOrganizationName()
return orgName.charAt(0).toUpperCase()
}
const getOrganizationName = () => {
if (user?.organization?.name) {
return user.organization.name
}
if (user?.organization?.fullName) {
return user.organization.fullName
}
return 'Организация'
}
const getCabinetType = () => {
if (!user?.organization?.type) return 'Кабинет'
switch (user.organization.type) {
case 'FULFILLMENT':
return 'Фулфилмент'
case 'SELLER':
return 'Селлер'
case 'LOGIST':
return 'Логистика'
case 'WHOLESALE':
return 'Оптовик'
default:
return 'Кабинет'
}
}
const handleSettingsClick = () => {
router.push('/settings')
}
const handleMarketClick = () => {
router.push('/market')
}
const isSettingsActive = pathname === '/settings'
const isMarketActive = pathname.startsWith('/market')
return (
<div className="fixed left-0 top-0 h-full w-56 bg-white/10 backdrop-blur-xl border-r border-white/20 p-3">
<div className="flex flex-col h-full">
{/* Информация о пользователе */}
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 mb-4">
<div className="flex items-center space-x-2">
<Avatar className="h-10 w-10">
{user?.avatar ? (
<AvatarImage
src={user.avatar}
alt="Аватар пользователя"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-1 mb-1">
<Building2 className="h-3 w-3 text-white/60" />
<p className="text-white text-xs font-medium truncate">
{getOrganizationName()}
</p>
</div>
<p className="text-white/60 text-xs truncate">
{getCabinetType()}
</p>
</div>
</div>
</Card>
{/* Навигация */}
<div className="space-y-1 mb-3">
<Button
variant={isMarketActive ? "secondary" : "ghost"}
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
isMarketActive
? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/80 hover:bg-white/10 hover:text-white'
} cursor-pointer`}
onClick={handleMarketClick}
>
<Store className="h-3 w-3 mr-2" />
Маркет
</Button>
<Button
variant={isSettingsActive ? "secondary" : "ghost"}
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
isSettingsActive
? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/80 hover:bg-white/10 hover:text-white'
} cursor-pointer`}
onClick={handleSettingsClick}
>
<Settings className="h-3 w-3 mr-2" />
Настройки профиля
</Button>
</div>
{/* Кнопка выхода */}
<div className="flex-1 flex items-end">
<Button
variant="ghost"
className="w-full justify-start text-white/80 hover:bg-red-500/20 hover:text-red-300 cursor-pointer h-8 text-xs"
onClick={logout}
>
<LogOut className="h-3 w-3 mr-2" />
Выйти
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,1130 @@
"use client"
import { useAuth } from '@/hooks/useAuth'
import { useMutation } from '@apollo/client'
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations'
import { formatPhone } from '@/lib/utils'
import S3Service from '@/services/s3-service'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Sidebar } from './sidebar'
import {
User,
Building2,
Phone,
Mail,
MapPin,
CreditCard,
Key,
Edit3,
ExternalLink,
Copy,
CheckCircle,
AlertTriangle,
MessageCircle,
Save,
RefreshCw,
Calendar,
Settings,
Upload,
Camera
} from 'lucide-react'
import { useState, useEffect } from 'react'
export function UserSettings() {
const { user } = 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 [partnerLink, setPartnerLink] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
// Инициализируем данные из пользователя и организации
const [formData, setFormData] = useState({
// Контактные данные организации
orgPhone: '', // телефон организации, не пользователя
managerName: '',
telegram: '',
whatsapp: '',
email: '',
// Организация - данные могут быть заполнены из DaData
orgName: '',
address: '',
// Юридические данные - могут быть заполнены из DaData
fullName: '',
inn: '',
ogrn: '',
registrationPlace: '',
// Финансовые данные - требуют ручного заполнения
bankName: '',
bik: '',
accountNumber: '',
corrAccount: ''
})
// Загружаем данные организации при монтировании компонента
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') {
customContacts = JSON.parse(org.managementPost)
}
} catch (e) {
console.warn('Ошибка парсинга managementPost:', e)
}
setFormData({
orgPhone: orgPhone,
managerName: customContacts?.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 || ''
})
}
}, [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') {
// Финансовые данные - всегда обязательны для бизнес-кабинетов
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 generatePartnerLink = async () => {
if (!user?.id) return
setIsGenerating(true)
setSaveMessage(null)
try {
// Генерируем уникальный код партнера
const partnerCode = btoa(user.id + Date.now()).replace(/[^a-zA-Z0-9]/g, '').substring(0, 12)
const link = `${window.location.origin}/register?partner=${partnerCode}`
setPartnerLink(link)
setSaveMessage({ type: 'success', text: 'Партнерская ссылка сгенерирована!' })
// TODO: Сохранить партнерский код в базе данных
console.log('Partner code generated:', partnerCode)
} catch (error) {
console.error('Error generating partner link:', error)
setSaveMessage({ type: 'error', text: 'Ошибка при генерации ссылки' })
} finally {
setIsGenerating(false)
}
}
const handleCopyLink = async () => {
if (!partnerLink) {
await generatePartnerLink()
return
}
try {
await navigator.clipboard.writeText(partnerLink)
setSaveMessage({ type: 'success', text: 'Ссылка скопирована!' })
} catch (error) {
console.error('Error copying to clipboard:', error)
setSaveMessage({ type: 'error', text: 'Ошибка при копировании' })
}
}
const handleOpenLink = async () => {
if (!partnerLink) {
await generatePartnerLink()
return
}
window.open(partnerLink, '_blank')
}
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)
// Обновляем аватар пользователя через GraphQL
const result = await updateUserProfile({
variables: {
input: {
avatar: avatarUrl
}
}
})
if (result.data?.updateUserProfile?.success) {
setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен! Обновляем страницу...' })
setTimeout(() => {
window.location.reload()
}, 1000)
} else {
throw new Error('Failed to update avatar')
}
} catch (error) {
console.error('Error uploading avatar:', error)
setSaveMessage({ type: 'error', text: 'Ошибка при загрузке аватара' })
} finally {
setIsUploadingAvatar(false)
}
}
// Функции для валидации и масок
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
const formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
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)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
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':
return value.length < 6 ? 'Минимум 5 символов после @' : null
case 'inn':
const innCleaned = value.replace(/\D/g, '')
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
return 'ИНН должен содержать 10 или 12 цифр'
}
return null
default:
return null
}
}
// Проверка наличия ошибок валидации
const hasValidationErrors = () => {
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn']
return fields.some(field => {
const value = formData[field as keyof typeof formData]
return getFieldError(field, value)
})
}
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 result = await updateUserProfile({
variables: {
input: {
orgPhone: formData.orgPhone,
managerName: formData.managerName,
telegram: formData.telegram,
whatsapp: formData.whatsapp,
email: formData.email,
bankName: formData.bankName,
bik: formData.bik,
accountNumber: formData.accountNumber,
corrAccount: formData.corrAccount
}
}
})
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())) {
console.warn('Invalid date string:', dateString)
return 'Неверная дата'
}
return date.toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
} catch (error) {
console.error('Error formatting date:', error, dateString)
return 'Ошибка даты'
}
}
return (
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
<Sidebar />
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
<div className="h-full w-full flex flex-col">
{/* Заголовок - фиксированная высота */}
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Настройки профиля</h1>
<p className="text-white/70 text-sm">Управление информацией о профиле и организации</p>
</div>
<div className="flex items-center gap-2">
{/* Компактный индикатор прогресса */}
{isIncomplete && (
<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">
Осталось {profileStatus.missingFields.length} {
profileStatus.missingFields.length === 1 ? 'поле' :
profileStatus.missingFields.length < 5 ? 'поля' : 'полей'
}
</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}
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>
{/* Сообщения о сохранении */}
{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') && (
<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>
)}
<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 gap-4 mb-6">
<div className="relative">
<Avatar className="h-16 w-16">
{user?.avatar ? (
<img
src={user.avatar}
alt="Аватар"
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
value={formData.orgPhone}
onChange={(e) => handleInputChange('orgPhone', e.target.value)}
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>
) : !formData.orgPhone && (
<p className="text-orange-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Рекомендуется указать
</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
value={formData.whatsapp}
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
placeholder="+7 (999) 999-99-99"
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 gap-3 mb-4">
<Building2 className="h-5 w-5 text-blue-400" />
<h3 className="text-lg font-semibold text-white">Организация и юридические данные</h3>
{(formData.inn || user?.organization?.inn) && (
<CheckCircle className="h-5 w-5 text-green-400 ml-auto" />
)}
</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">Название организации</Label>
<Input
value={formData.orgName || user?.organization?.name || ''}
onChange={(e) => handleInputChange('orgName', e.target.value)}
placeholder="Название организации"
readOnly={!isEditing || !!(formData.orgName || user?.organization?.name)}
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.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">
{user?.organization?.managementName && (
<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"
/>
</div>
)}
{user?.organization?.status && (
<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>
)}
</div>
</Card>
</TabsContent>
{/* Финансовые данные */}
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
<TabsContent value="financial" className="flex-1 overflow-hidden">
<Card className="glass-card p-6 h-full overflow-auto">
<div className="flex items-center gap-3 mb-6">
<CreditCard className="h-5 w-5 text-red-400" />
<h3 className="text-lg font-semibold text-white">Финансовые данные</h3>
{formData.bankName && formData.bik && formData.accountNumber && formData.corrAccount && (
<CheckCircle className="h-5 w-5 text-green-400 ml-auto" />
)}
</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 gap-3 mb-6">
<Key className="h-5 w-5 text-green-400" />
<h3 className="text-lg font-semibold text-white">API ключи маркетплейсов</h3>
{user?.organization?.apiKeys?.length > 0 && (
<CheckCircle className="h-5 w-5 text-green-400 ml-auto" />
)}
</div>
<div className="space-y-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
<Input
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : ''}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
{user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') && (
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
API ключ настроен
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
<Input
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : ''}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
{user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') && (
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
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 gap-3 mb-6">
<Key className="h-5 w-5 text-green-400" />
<h3 className="text-lg font-semibold text-white">Инструменты</h3>
</div>
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
<div className="space-y-6">
<div>
<h4 className="text-white font-medium mb-2">Партнерская программа</h4>
<p className="text-white/70 text-sm mb-4">
Приглашайте новых контрагентов по уникальной ссылке. При регистрации они автоматически становятся вашими партнерами.
</p>
<div className="space-y-3">
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="glass-secondary text-white hover:text-white cursor-pointer text-xs px-3 py-2"
onClick={generatePartnerLink}
disabled={isGenerating}
>
<RefreshCw className={`h-3 w-3 mr-1 ${isGenerating ? 'animate-spin' : ''}`} />
{isGenerating ? 'Генерируем...' : 'Сгенерировать ссылку'}
</Button>
</div>
{partnerLink && (
<div className="space-y-2">
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="glass-secondary text-white hover:text-white cursor-pointer text-xs px-3 py-2"
onClick={handleOpenLink}
>
<ExternalLink className="h-3 w-3 mr-1" />
Открыть ссылку
</Button>
<Button
size="sm"
variant="outline"
className="glass-secondary text-white hover:text-white cursor-pointer px-2"
onClick={handleCopyLink}
>
<Copy className="h-3 w-3" />
</Button>
</div>
<p className="text-white/60 text-xs">
Ваша партнерская ссылка сгенерирована и готова к использованию
</p>
</div>
)}
</div>
</div>
</div>
)}
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</main>
</div>
)
}