
- Обновлена схема Prisma с новыми полями и связями - Актуализированы правила системы в rules-complete.md - Оптимизированы GraphQL типы, запросы и мутации - Улучшены компоненты интерфейса и валидация данных - Исправлены критические ESLint ошибки: удалены неиспользуемые импорты и переменные - Добавлены тестовые файлы для проверки функционала 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1656 lines
76 KiB
TypeScript
1656 lines
76 KiB
TypeScript
'use client'
|
||
|
||
import { useMutation } from '@apollo/client'
|
||
import {
|
||
User,
|
||
Building2,
|
||
Phone,
|
||
Mail,
|
||
MapPin,
|
||
CreditCard,
|
||
Key,
|
||
Edit3,
|
||
ExternalLink,
|
||
Copy,
|
||
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 [partnerLink, setPartnerLink] = useState('')
|
||
const [isGenerating, setIsGenerating] = useState(false)
|
||
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.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 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: Сохранить партнерский код в базе данных
|
||
} 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)
|
||
|
||
// Сразу обновляем локальное состояние для мгновенного отображения
|
||
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.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"
|
||
/>
|
||
{(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"
|
||
/>
|
||
{(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>
|
||
|
||
{(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>
|
||
)
|
||
}
|