Добавлены новые зависимости для работы с эмодзи и улучшена структура базы данных. Реализована модель сообщений и обновлены компоненты для поддержки новых функций мессенджера. Обновлены запросы и мутации для работы с сообщениями и чатом.
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@ -32,6 +32,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"emoji-picker-react": "^4.13.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
@ -6302,6 +6303,21 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-picker-react": {
|
||||||
|
"version": "4.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.13.2.tgz",
|
||||||
|
"integrity": "sha512-azaJQLTshEOZVhksgU136izJWJyZ4Clx6xQ6Vctzk1gOdPPAUbTa/JYDwZJ8rh97QxnjpyeftXl99eRlYr3vNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"flairup": "1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
@ -7140,6 +7156,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/flairup": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||||
|
@ -33,6 +33,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"emoji-picker-react": "^4.13.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
|
@ -15,6 +15,7 @@ model User {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
phone String @unique
|
phone String @unique
|
||||||
avatar String? // URL аватара в S3
|
avatar String? // URL аватара в S3
|
||||||
|
managerName String? // Имя управляющего
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -25,6 +26,9 @@ model User {
|
|||||||
// SMS коды для авторизации
|
// SMS коды для авторизации
|
||||||
smsCodes SmsCode[]
|
smsCodes SmsCode[]
|
||||||
|
|
||||||
|
// Отправленные сообщения
|
||||||
|
sentMessages Message[] @relation("SentMessages")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,6 +110,10 @@ model Organization {
|
|||||||
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
|
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
|
||||||
counterpartyOf Counterparty[] @relation("CounterpartyOf")
|
counterpartyOf Counterparty[] @relation("CounterpartyOf")
|
||||||
|
|
||||||
|
// Сообщения
|
||||||
|
sentMessages Message[] @relation("SentMessages")
|
||||||
|
receivedMessages Message[] @relation("ReceivedMessages")
|
||||||
|
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,3 +197,41 @@ enum CounterpartyRequestStatus {
|
|||||||
REJECTED // Отклонена
|
REJECTED // Отклонена
|
||||||
CANCELLED // Отменена отправителем
|
CANCELLED // Отменена отправителем
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Модель сообщений в мессенджере
|
||||||
|
model Message {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
content String? // Текст сообщения (nullable для медиа)
|
||||||
|
type MessageType @default(TEXT)
|
||||||
|
voiceUrl String? // URL голосового файла в S3
|
||||||
|
voiceDuration Int? // Длительность голосового сообщения в секундах
|
||||||
|
fileUrl String? // URL файла/изображения в S3
|
||||||
|
fileName String? // Оригинальное имя файла
|
||||||
|
fileSize Int? // Размер файла в байтах
|
||||||
|
fileType String? // MIME тип файла
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Отправитель
|
||||||
|
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||||
|
senderId String
|
||||||
|
senderOrganization Organization @relation("SentMessages", fields: [senderOrganizationId], references: [id])
|
||||||
|
senderOrganizationId String
|
||||||
|
|
||||||
|
// Получатель
|
||||||
|
receiverOrganization Organization @relation("ReceivedMessages", fields: [receiverOrganizationId], references: [id])
|
||||||
|
receiverOrganizationId String
|
||||||
|
|
||||||
|
@@index([senderOrganizationId, receiverOrganizationId, createdAt])
|
||||||
|
@@index([receiverOrganizationId, isRead])
|
||||||
|
@@map("messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Типы сообщений
|
||||||
|
enum MessageType {
|
||||||
|
TEXT
|
||||||
|
VOICE
|
||||||
|
IMAGE
|
||||||
|
FILE
|
||||||
|
}
|
||||||
|
167
src/app/api/upload-file/route.ts
Normal file
167
src/app/api/upload-file/route.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
|
||||||
|
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
region: 'ru-1',
|
||||||
|
endpoint: 'https://s3.twcstorage.ru',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: 'I6XD2OR7YO2ZN6L6Z629',
|
||||||
|
secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ'
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const BUCKET_NAME = '617774af-sfera'
|
||||||
|
|
||||||
|
// Разрешенные типы файлов
|
||||||
|
const ALLOWED_IMAGE_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif'
|
||||||
|
]
|
||||||
|
|
||||||
|
const ALLOWED_FILE_TYPES = [
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'text/plain',
|
||||||
|
'application/zip',
|
||||||
|
'application/x-zip-compressed',
|
||||||
|
'application/json'
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get('file') as File
|
||||||
|
const userId = formData.get('userId') as string
|
||||||
|
const messageType = formData.get('messageType') as string // 'IMAGE' или 'FILE'
|
||||||
|
|
||||||
|
if (!file || !userId || !messageType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File, userId and messageType are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что файл не пустой
|
||||||
|
if (file.size === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File is empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем имя файла
|
||||||
|
if (!file.name || file.name.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid file name' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем тип файла в зависимости от типа сообщения
|
||||||
|
const isImage = messageType === 'IMAGE'
|
||||||
|
const allowedTypes = isImage ? ALLOWED_IMAGE_TYPES : [...ALLOWED_IMAGE_TYPES, ...ALLOWED_FILE_TYPES]
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File type ${file.type} is not allowed` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем размер файла
|
||||||
|
const maxSize = isImage ? 10 * 1024 * 1024 : 50 * 1024 * 1024 // 10MB для изображений, 50MB для файлов
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const maxSizeMB = maxSize / (1024 * 1024)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File size must be less than ${maxSizeMB}MB` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем уникальное имя файла
|
||||||
|
const timestamp = Date.now()
|
||||||
|
// Более безопасная очистка имени файла
|
||||||
|
const safeFileName = file.name
|
||||||
|
.replace(/[^\w\s.-]/g, '_') // Заменяем недопустимые символы
|
||||||
|
.replace(/\s+/g, '_') // Заменяем пробелы на подчеркивания
|
||||||
|
.replace(/_{2,}/g, '_') // Убираем множественные подчеркивания
|
||||||
|
.toLowerCase() // Приводим к нижнему регистру
|
||||||
|
|
||||||
|
const folder = isImage ? 'images' : 'files'
|
||||||
|
const key = `${folder}/${userId}/${timestamp}-${safeFileName}`
|
||||||
|
|
||||||
|
// Конвертируем файл в Buffer
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
|
||||||
|
// Очищаем метаданные от недопустимых символов
|
||||||
|
const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_')
|
||||||
|
const cleanUserId = userId.replace(/[^\w-]/g, '')
|
||||||
|
const cleanMessageType = messageType.replace(/[^\w]/g, '')
|
||||||
|
|
||||||
|
// Загружаем в S3
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: file.type,
|
||||||
|
ACL: 'public-read',
|
||||||
|
Metadata: {
|
||||||
|
originalname: cleanOriginalName,
|
||||||
|
uploadedby: cleanUserId,
|
||||||
|
messagetype: cleanMessageType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await s3Client.send(command)
|
||||||
|
|
||||||
|
// Возвращаем URL файла и метаданные
|
||||||
|
const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}`
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
originalName: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
messageType
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file:', error)
|
||||||
|
|
||||||
|
// Логируем детали ошибки
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Error message:', error.message)
|
||||||
|
console.error('Error stack:', error.stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorMessage = 'Failed to upload file'
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Проверяем специфичные ошибки
|
||||||
|
if (error.message.includes('Invalid character in header')) {
|
||||||
|
errorMessage = 'Invalid characters in file name or metadata'
|
||||||
|
} else if (error.message.includes('AccessDenied')) {
|
||||||
|
errorMessage = 'Access denied to storage'
|
||||||
|
} else if (error.message.includes('NoSuchBucket')) {
|
||||||
|
errorMessage = 'Storage bucket not found'
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: errorMessage, success: false },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
82
src/app/api/upload-voice/route.ts
Normal file
82
src/app/api/upload-voice/route.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
|
||||||
|
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
region: 'ru-1',
|
||||||
|
endpoint: 'https://s3.twcstorage.ru',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: 'I6XD2OR7YO2ZN6L6Z629',
|
||||||
|
secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ'
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const BUCKET_NAME = '617774af-sfera'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get('file') as File
|
||||||
|
const userId = formData.get('userId') as string
|
||||||
|
|
||||||
|
if (!file || !userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File and userId are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем тип файла (поддерживаем аудио форматы)
|
||||||
|
if (!file.type.startsWith('audio/')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only audio files are allowed' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем размер файла (10MB для голосовых сообщений)
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File size must be less than 10MB' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем уникальное имя файла
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const extension = file.name.split('.').pop() || 'wav'
|
||||||
|
const key = `voice-messages/${userId}/${timestamp}.${extension}`
|
||||||
|
|
||||||
|
// Конвертируем файл в Buffer
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
|
||||||
|
// Загружаем в S3
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: file.type,
|
||||||
|
ACL: 'public-read'
|
||||||
|
})
|
||||||
|
|
||||||
|
await s3Client.send(command)
|
||||||
|
|
||||||
|
// Возвращаем URL файла и метаданные
|
||||||
|
const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}`
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
duration: 0, // Длительность будет вычислена на фронтенде
|
||||||
|
size: file.size
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading voice message:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to upload voice message' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
10
src/app/messenger/page.tsx
Normal file
10
src/app/messenger/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
|
import { MessengerDashboard } from "@/components/messenger/messenger-dashboard"
|
||||||
|
|
||||||
|
export default function MessengerPage() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<MessengerDashboard />
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
@ -23,6 +23,7 @@ interface OrganizationData {
|
|||||||
interface ApiKeyValidation {
|
interface ApiKeyValidation {
|
||||||
sellerId?: string
|
sellerId?: string
|
||||||
sellerName?: string
|
sellerName?: string
|
||||||
|
tradeMark?: string
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ interface OrganizationData {
|
|||||||
interface ApiKeyValidation {
|
interface ApiKeyValidation {
|
||||||
sellerId?: string
|
sellerId?: string
|
||||||
sellerName?: string
|
sellerName?: string
|
||||||
|
tradeMark?: string
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,9 +250,9 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr
|
|||||||
WB
|
WB
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{data.wbApiValidation?.sellerName ? (
|
{data.wbApiValidation?.tradeMark || data.wbApiValidation?.sellerName ? (
|
||||||
<span className="text-white/70 text-xs max-w-[120px] text-right truncate">
|
<span className="text-white/70 text-xs max-w-[120px] text-right truncate">
|
||||||
{data.wbApiValidation.sellerName}
|
{data.wbApiValidation.tradeMark || data.wbApiValidation.sellerName}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="glass-secondary text-green-300 border-green-400/30 text-xs flex items-center gap-1">
|
<Badge variant="outline" className="glass-secondary text-green-300 border-green-400/30 text-xs flex items-center gap-1">
|
||||||
@ -263,9 +264,17 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr
|
|||||||
|
|
||||||
{data.wbApiValidation && (
|
{data.wbApiValidation && (
|
||||||
<>
|
<>
|
||||||
{data.wbApiValidation.sellerName && (
|
{data.wbApiValidation.tradeMark && (
|
||||||
<div className="flex items-center justify-between pl-4">
|
<div className="flex items-center justify-between pl-4">
|
||||||
<span className="text-white/50 text-xs">Магазин:</span>
|
<span className="text-white/50 text-xs">Торговая марка:</span>
|
||||||
|
<span className="text-white/70 text-xs max-w-[160px] text-right truncate">
|
||||||
|
{data.wbApiValidation.tradeMark}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.wbApiValidation.sellerName && data.wbApiValidation.sellerName !== data.wbApiValidation.tradeMark && (
|
||||||
|
<div className="flex items-center justify-between pl-4">
|
||||||
|
<span className="text-white/50 text-xs">Продавец:</span>
|
||||||
<span className="text-white/70 text-xs max-w-[160px] text-right truncate">
|
<span className="text-white/70 text-xs max-w-[160px] text-right truncate">
|
||||||
{data.wbApiValidation.sellerName}
|
{data.wbApiValidation.sellerName}
|
||||||
</span>
|
</span>
|
||||||
|
@ -15,6 +15,7 @@ import { getAuthToken } from '@/lib/apollo-client'
|
|||||||
interface ApiValidationData {
|
interface ApiValidationData {
|
||||||
sellerId?: string
|
sellerId?: string
|
||||||
sellerName?: string
|
sellerName?: string
|
||||||
|
tradeMark?: string
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,12 +98,14 @@ export function MarketplaceApiStep({ onNext, onBack }: MarketplaceApiStepProps)
|
|||||||
setWbValidationData({
|
setWbValidationData({
|
||||||
sellerId: validationData.sellerId,
|
sellerId: validationData.sellerId,
|
||||||
sellerName: validationData.sellerName,
|
sellerName: validationData.sellerName,
|
||||||
|
tradeMark: validationData.tradeMark,
|
||||||
isValid: true
|
isValid: true
|
||||||
})
|
})
|
||||||
} else if (marketplace === 'ozon') {
|
} else if (marketplace === 'ozon') {
|
||||||
setOzonValidationData({
|
setOzonValidationData({
|
||||||
sellerId: validationData.sellerId,
|
sellerId: validationData.sellerId,
|
||||||
sellerName: validationData.sellerName,
|
sellerName: validationData.sellerName,
|
||||||
|
tradeMark: validationData.tradeMark,
|
||||||
isValid: true
|
isValid: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
Building2,
|
Building2,
|
||||||
Store
|
Store,
|
||||||
|
MessageCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@ -58,8 +59,13 @@ export function Sidebar() {
|
|||||||
router.push('/market')
|
router.push('/market')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMessengerClick = () => {
|
||||||
|
router.push('/messenger')
|
||||||
|
}
|
||||||
|
|
||||||
const isSettingsActive = pathname === '/settings'
|
const isSettingsActive = pathname === '/settings'
|
||||||
const isMarketActive = pathname.startsWith('/market')
|
const isMarketActive = pathname.startsWith('/market')
|
||||||
|
const isMessengerActive = pathname.startsWith('/messenger')
|
||||||
|
|
||||||
return (
|
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="fixed left-0 top-0 h-full w-56 bg-white/10 backdrop-blur-xl border-r border-white/20 p-3">
|
||||||
@ -67,30 +73,33 @@ export function Sidebar() {
|
|||||||
|
|
||||||
|
|
||||||
{/* Информация о пользователе */}
|
{/* Информация о пользователе */}
|
||||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 mb-4">
|
<Card className="bg-gradient-to-br from-white/15 to-white/5 backdrop-blur border border-white/30 p-4 mb-4 shadow-lg">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<Avatar className="h-10 w-10">
|
<div className="relative">
|
||||||
{user?.avatar ? (
|
<Avatar className="h-12 w-12 flex-shrink-0 ring-2 ring-white/20">
|
||||||
<AvatarImage
|
{user?.avatar ? (
|
||||||
src={user.avatar}
|
<AvatarImage
|
||||||
alt="Аватар пользователя"
|
src={user.avatar}
|
||||||
className="w-full h-full object-cover"
|
alt="Аватар пользователя"
|
||||||
/>
|
className="w-full h-full object-cover"
|
||||||
) : null}
|
/>
|
||||||
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
) : null}
|
||||||
{getInitials()}
|
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-sm font-semibold">
|
||||||
</AvatarFallback>
|
{getInitials()}
|
||||||
</Avatar>
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-400 rounded-full border-2 border-white/20"></div>
|
||||||
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-1 mb-1">
|
<p className="text-white text-sm font-semibold truncate mb-1" title={getOrganizationName()}>
|
||||||
<Building2 className="h-3 w-3 text-white/60" />
|
{getOrganizationName()}
|
||||||
<p className="text-white text-xs font-medium truncate">
|
</p>
|
||||||
{getOrganizationName()}
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className="w-2 h-2 bg-purple-400 rounded-full flex-shrink-0"></div>
|
||||||
|
<p className="text-white/70 text-xs font-medium">
|
||||||
|
{getCabinetType()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/60 text-xs truncate">
|
|
||||||
{getCabinetType()}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@ -110,6 +119,19 @@ export function Sidebar() {
|
|||||||
Маркет
|
Маркет
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isMessengerActive ? "secondary" : "ghost"}
|
||||||
|
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||||
|
isMessengerActive
|
||||||
|
? 'bg-white/20 text-white hover:bg-white/30'
|
||||||
|
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||||
|
} cursor-pointer`}
|
||||||
|
onClick={handleMessengerClick}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-3 w-3 mr-2" />
|
||||||
|
Мессенджер
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isSettingsActive ? "secondary" : "ghost"}
|
variant={isSettingsActive ? "secondary" : "ghost"}
|
||||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||||
|
@ -70,7 +70,11 @@ export function UserSettings() {
|
|||||||
bankName: '',
|
bankName: '',
|
||||||
bik: '',
|
bik: '',
|
||||||
accountNumber: '',
|
accountNumber: '',
|
||||||
corrAccount: ''
|
corrAccount: '',
|
||||||
|
|
||||||
|
// API ключи маркетплейсов
|
||||||
|
wildberriesApiKey: '',
|
||||||
|
ozonApiKey: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Загружаем данные организации при монтировании компонента
|
// Загружаем данные организации при монтировании компонента
|
||||||
@ -122,7 +126,7 @@ export function UserSettings() {
|
|||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
orgPhone: orgPhone,
|
orgPhone: orgPhone,
|
||||||
managerName: customContacts?.managerName || '',
|
managerName: user?.managerName || '',
|
||||||
telegram: customContacts?.telegram || '',
|
telegram: customContacts?.telegram || '',
|
||||||
whatsapp: customContacts?.whatsapp || '',
|
whatsapp: customContacts?.whatsapp || '',
|
||||||
email: email,
|
email: email,
|
||||||
@ -135,7 +139,9 @@ export function UserSettings() {
|
|||||||
bankName: customContacts?.bankDetails?.bankName || '',
|
bankName: customContacts?.bankDetails?.bankName || '',
|
||||||
bik: customContacts?.bankDetails?.bik || '',
|
bik: customContacts?.bankDetails?.bik || '',
|
||||||
accountNumber: customContacts?.bankDetails?.accountNumber || '',
|
accountNumber: customContacts?.bankDetails?.accountNumber || '',
|
||||||
corrAccount: customContacts?.bankDetails?.corrAccount || ''
|
corrAccount: customContacts?.bankDetails?.corrAccount || '',
|
||||||
|
wildberriesApiKey: '',
|
||||||
|
ozonApiKey: ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
@ -176,8 +182,8 @@ export function UserSettings() {
|
|||||||
|
|
||||||
// Дополнительные поля в зависимости от типа кабинета
|
// Дополнительные поля в зависимости от типа кабинета
|
||||||
const additionalFields = []
|
const additionalFields = []
|
||||||
if (user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') {
|
if (user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') {
|
||||||
// Финансовые данные - всегда обязательны для бизнес-кабинетов
|
// Финансовые данные - всегда обязательны для всех типов кабинетов
|
||||||
additionalFields.push(
|
additionalFields.push(
|
||||||
{ field: 'bankName', label: 'Название банка', value: formData.bankName },
|
{ field: 'bankName', label: 'Название банка', value: formData.bankName },
|
||||||
{ field: 'bik', label: 'БИК', value: formData.bik },
|
{ field: 'bik', label: 'БИК', value: formData.bik },
|
||||||
@ -386,13 +392,30 @@ export function UserSettings() {
|
|||||||
const cleaned = value.replace(/\D/g, '')
|
const cleaned = value.replace(/\D/g, '')
|
||||||
return cleaned.length !== 11 ? 'Неверный формат телефона' : null
|
return cleaned.length !== 11 ? 'Неверный формат телефона' : null
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
return value.length < 6 ? 'Минимум 5 символов после @' : null
|
// Проверяем что после @ есть минимум 5 символов
|
||||||
|
const usernameLength = value.startsWith('@') ? value.length - 1 : value.length
|
||||||
|
return usernameLength < 5 ? 'Минимум 5 символов после @' : null
|
||||||
case 'inn':
|
case 'inn':
|
||||||
|
// Игнорируем автоматически сгенерированные ИНН селлеров
|
||||||
|
if (value.startsWith('SELLER_')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const innCleaned = value.replace(/\D/g, '')
|
const innCleaned = value.replace(/\D/g, '')
|
||||||
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
|
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
|
||||||
return 'ИНН должен содержать 10 или 12 цифр'
|
return 'ИНН должен содержать 10 или 12 цифр'
|
||||||
}
|
}
|
||||||
return null
|
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:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -400,11 +423,20 @@ export function UserSettings() {
|
|||||||
|
|
||||||
// Проверка наличия ошибок валидации
|
// Проверка наличия ошибок валидации
|
||||||
const hasValidationErrors = () => {
|
const hasValidationErrors = () => {
|
||||||
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn']
|
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn', 'bankName', 'bik', 'accountNumber', 'corrAccount']
|
||||||
return fields.some(field => {
|
|
||||||
|
// Проверяем ошибки валидации только в заполненных полях
|
||||||
|
const hasErrors = fields.some(field => {
|
||||||
const value = formData[field as keyof typeof formData]
|
const value = formData[field as keyof typeof formData]
|
||||||
return getFieldError(field, value)
|
// Проверяем ошибки только для заполненных полей
|
||||||
|
if (!value || !value.trim()) return false
|
||||||
|
|
||||||
|
const error = getFieldError(field, value)
|
||||||
|
return error !== null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Убираем проверку обязательных полей - пользователь может заполнять постепенно
|
||||||
|
return hasErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@ -437,19 +469,33 @@ export function UserSettings() {
|
|||||||
setSaveMessage({ type: 'success', text: 'Данные организации обновлены. Сохраняем профиль...' })
|
setSaveMessage({ type: 'success', text: 'Данные организации обновлены. Сохраняем профиль...' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подготавливаем только заполненные поля для отправки
|
||||||
|
const inputData: {
|
||||||
|
orgPhone?: string
|
||||||
|
managerName?: string
|
||||||
|
telegram?: string
|
||||||
|
whatsapp?: string
|
||||||
|
email?: string
|
||||||
|
bankName?: string
|
||||||
|
bik?: string
|
||||||
|
accountNumber?: string
|
||||||
|
corrAccount?: 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()
|
||||||
|
|
||||||
const result = await updateUserProfile({
|
const result = await updateUserProfile({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: inputData
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -516,19 +562,18 @@ export function UserSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Компактный индикатор прогресса */}
|
{/* Компактный индикатор прогресса */}
|
||||||
{isIncomplete && (
|
<div className="flex items-center gap-2 mr-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">
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
)}
|
<div className="hidden sm:block text-xs text-white/70">
|
||||||
|
{isIncomplete ? (
|
||||||
|
<>Заполнено {profileStatus.percentage}% профиля</>
|
||||||
|
) : (
|
||||||
|
<>Профиль полностью заполнен</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<>
|
<>
|
||||||
@ -590,10 +635,10 @@ export function UserSettings() {
|
|||||||
<Building2 className="h-4 w-4 mr-2" />
|
<Building2 className="h-4 w-4 mr-2" />
|
||||||
Организация
|
Организация
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
|
{(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">
|
<TabsTrigger value="financial" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
||||||
<CreditCard className="h-4 w-4 mr-2" />
|
<CreditCard className="h-4 w-4 mr-2" />
|
||||||
Финансовые
|
Финансы
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{user?.organization?.type === 'SELLER' && (
|
{user?.organization?.type === 'SELLER' && (
|
||||||
@ -602,10 +647,12 @@ export function UserSettings() {
|
|||||||
API
|
API
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
{user?.organization?.type !== 'SELLER' && (
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
||||||
Инструменты
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
</TabsTrigger>
|
Инструменты
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Профиль пользователя */}
|
{/* Профиль пользователя */}
|
||||||
@ -671,7 +718,7 @@ export function UserSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.orgPhone}
|
value={formData.orgPhone || ''}
|
||||||
onChange={(e) => handleInputChange('orgPhone', e.target.value)}
|
onChange={(e) => handleInputChange('orgPhone', e.target.value)}
|
||||||
placeholder="+7 (999) 999-99-99"
|
placeholder="+7 (999) 999-99-99"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -679,23 +726,18 @@ export function UserSettings() {
|
|||||||
getFieldError('orgPhone', formData.orgPhone) ? 'border-red-400' : ''
|
getFieldError('orgPhone', formData.orgPhone) ? 'border-red-400' : ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{getFieldError('orgPhone', formData.orgPhone) ? (
|
{getFieldError('orgPhone', formData.orgPhone) && (
|
||||||
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
{getFieldError('orgPhone', formData.orgPhone)}
|
{getFieldError('orgPhone', formData.orgPhone)}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Имя управляющего</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Имя управляющего</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.managerName}
|
value={formData.managerName || ''}
|
||||||
onChange={(e) => handleInputChange('managerName', e.target.value)}
|
onChange={(e) => handleInputChange('managerName', e.target.value)}
|
||||||
placeholder="Иван Иванов"
|
placeholder="Иван Иванов"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -719,7 +761,7 @@ export function UserSettings() {
|
|||||||
Telegram
|
Telegram
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.telegram}
|
value={formData.telegram || ''}
|
||||||
onChange={(e) => handleInputChange('telegram', e.target.value)}
|
onChange={(e) => handleInputChange('telegram', e.target.value)}
|
||||||
placeholder="@username"
|
placeholder="@username"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -741,7 +783,7 @@ export function UserSettings() {
|
|||||||
WhatsApp
|
WhatsApp
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.whatsapp}
|
value={formData.whatsapp || ''}
|
||||||
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
|
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
|
||||||
placeholder="+7 (999) 999-99-99"
|
placeholder="+7 (999) 999-99-99"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -764,7 +806,7 @@ export function UserSettings() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email || ''}
|
||||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
placeholder="example@company.com"
|
placeholder="example@company.com"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -807,14 +849,25 @@ export function UserSettings() {
|
|||||||
{/* Названия */}
|
{/* Названия */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Название организации</Label>
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
|
{user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.orgName || user?.organization?.name || ''}
|
value={formData.orgName || user?.organization?.name || ''}
|
||||||
onChange={(e) => handleInputChange('orgName', e.target.value)}
|
onChange={(e) => handleInputChange('orgName', e.target.value)}
|
||||||
placeholder="Название организации"
|
placeholder={user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
|
||||||
readOnly={!isEditing || !!(formData.orgName || user?.organization?.name)}
|
readOnly={true}
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -904,27 +957,29 @@ export function UserSettings() {
|
|||||||
|
|
||||||
{/* Руководитель и статус */}
|
{/* Руководитель и статус */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{user?.organization?.managementName && (
|
<div>
|
||||||
<div>
|
<Label className="text-white/80 text-sm mb-2 block">Руководитель организации</Label>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Руководитель</Label>
|
<Input
|
||||||
<Input
|
value={user?.organization?.managementName || 'Данные не указаны в реестре'}
|
||||||
value={user.organization.managementName}
|
readOnly
|
||||||
readOnly
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
placeholder="Данные отсутствуют в федеральном реестре"
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="text-white/50 text-xs mt-1">
|
||||||
)}
|
{user?.organization?.managementName
|
||||||
|
? 'Данные из федерального реестра'
|
||||||
|
: 'Автоматически заполняется из федерального реестра при указании ИНН'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{user?.organization?.status && (
|
<div>
|
||||||
<div>
|
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
|
<Input
|
||||||
<Input
|
value={user?.organization?.status === 'ACTIVE' ? 'Действующая' : user?.organization?.status || 'Статус не указан'}
|
||||||
value={user.organization.status === 'ACTIVE' ? 'Действующая' : user.organization.status}
|
readOnly
|
||||||
readOnly
|
className="glass-input text-white h-10 read-only:opacity-70"
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Дата регистрации */}
|
{/* Дата регистрации */}
|
||||||
@ -950,7 +1005,7 @@ export function UserSettings() {
|
|||||||
|
|
||||||
|
|
||||||
{/* Финансовые данные */}
|
{/* Финансовые данные */}
|
||||||
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
|
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
|
||||||
<TabsContent value="financial" className="flex-1 overflow-hidden">
|
<TabsContent value="financial" className="flex-1 overflow-hidden">
|
||||||
<Card className="glass-card p-6 h-full overflow-auto">
|
<Card className="glass-card p-6 h-full overflow-auto">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
@ -965,7 +1020,7 @@ export function UserSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Название банка</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Название банка</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.bankName}
|
value={formData.bankName || ''}
|
||||||
onChange={(e) => handleInputChange('bankName', e.target.value)}
|
onChange={(e) => handleInputChange('bankName', e.target.value)}
|
||||||
placeholder="ПАО Сбербанк"
|
placeholder="ПАО Сбербанк"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -977,7 +1032,7 @@ export function UserSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">БИК</Label>
|
<Label className="text-white/80 text-sm mb-2 block">БИК</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.bik}
|
value={formData.bik || ''}
|
||||||
onChange={(e) => handleInputChange('bik', e.target.value)}
|
onChange={(e) => handleInputChange('bik', e.target.value)}
|
||||||
placeholder="044525225"
|
placeholder="044525225"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -988,7 +1043,7 @@ export function UserSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Корр. счет</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Корр. счет</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.corrAccount}
|
value={formData.corrAccount || ''}
|
||||||
onChange={(e) => handleInputChange('corrAccount', e.target.value)}
|
onChange={(e) => handleInputChange('corrAccount', e.target.value)}
|
||||||
placeholder="30101810400000000225"
|
placeholder="30101810400000000225"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -1000,7 +1055,7 @@ export function UserSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Расчетный счет</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Расчетный счет</Label>
|
||||||
<Input
|
<Input
|
||||||
value={formData.accountNumber}
|
value={formData.accountNumber || ''}
|
||||||
onChange={(e) => handleInputChange('accountNumber', e.target.value)}
|
onChange={(e) => handleInputChange('accountNumber', e.target.value)}
|
||||||
placeholder="40702810123456789012"
|
placeholder="40702810123456789012"
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
@ -1028,14 +1083,16 @@ export function UserSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
|
||||||
<Input
|
<Input
|
||||||
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : ''}
|
value={isEditing ? (formData.wildberriesApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : '')}
|
||||||
readOnly
|
onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)}
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
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') && (
|
{(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">
|
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4" />
|
<CheckCircle className="h-4 w-4" />
|
||||||
API ключ настроен
|
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1043,14 +1100,16 @@ export function UserSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
|
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
|
||||||
<Input
|
<Input
|
||||||
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : ''}
|
value={isEditing ? (formData.ozonApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : '')}
|
||||||
readOnly
|
onChange={(e) => handleInputChange('ozonApiKey', e.target.value)}
|
||||||
className="glass-input text-white h-10 read-only:opacity-70"
|
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') && (
|
{(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">
|
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4" />
|
<CheckCircle className="h-4 w-4" />
|
||||||
API ключ настроен
|
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,21 +2,18 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation } from '@apollo/client'
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Clock,
|
|
||||||
Send,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
ArrowDownCircle
|
ArrowDownCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { OrganizationCard } from './organization-card'
|
import { OrganizationCard } from './organization-card'
|
||||||
import { GET_MY_COUNTERPARTIES, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
import { OrganizationAvatar } from './organization-avatar'
|
||||||
|
import { GET_MY_COUNTERPARTIES, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS, SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||||
import { RESPOND_TO_COUNTERPARTY_REQUEST, CANCEL_COUNTERPARTY_REQUEST, REMOVE_COUNTERPARTY } from '@/graphql/mutations'
|
import { RESPOND_TO_COUNTERPARTY_REQUEST, CANCEL_COUNTERPARTY_REQUEST, REMOVE_COUNTERPARTY } from '@/graphql/mutations'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
@ -47,28 +44,43 @@ export function MarketCounterparties() {
|
|||||||
const { data: outgoingData, loading: outgoingLoading, refetch: refetchOutgoing } = useQuery(GET_OUTGOING_REQUESTS)
|
const { data: outgoingData, loading: outgoingLoading, refetch: refetchOutgoing } = useQuery(GET_OUTGOING_REQUESTS)
|
||||||
|
|
||||||
const [respondToRequest] = useMutation(RESPOND_TO_COUNTERPARTY_REQUEST, {
|
const [respondToRequest] = useMutation(RESPOND_TO_COUNTERPARTY_REQUEST, {
|
||||||
onCompleted: () => {
|
refetchQueries: [
|
||||||
refetchIncoming()
|
{ query: GET_INCOMING_REQUESTS },
|
||||||
refetchCounterparties()
|
{ query: GET_MY_COUNTERPARTIES },
|
||||||
}
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
|
||||||
|
],
|
||||||
|
awaitRefetchQueries: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const [cancelRequest] = useMutation(CANCEL_COUNTERPARTY_REQUEST, {
|
const [cancelRequest] = useMutation(CANCEL_COUNTERPARTY_REQUEST, {
|
||||||
onCompleted: () => {
|
refetchQueries: [
|
||||||
refetchOutgoing()
|
{ query: GET_OUTGOING_REQUESTS },
|
||||||
}
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
|
||||||
|
],
|
||||||
|
awaitRefetchQueries: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const [removeCounterparty] = useMutation(REMOVE_COUNTERPARTY, {
|
const [removeCounterparty] = useMutation(REMOVE_COUNTERPARTY, {
|
||||||
onCompleted: () => {
|
refetchQueries: [
|
||||||
refetchCounterparties()
|
{ query: GET_MY_COUNTERPARTIES },
|
||||||
}
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
|
||||||
|
],
|
||||||
|
awaitRefetchQueries: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleAcceptRequest = async (requestId: string) => {
|
const handleAcceptRequest = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
await respondToRequest({
|
await respondToRequest({
|
||||||
variables: { requestId, response: 'ACCEPTED' }
|
variables: { requestId, accept: true }
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при принятии заявки:', error)
|
console.error('Ошибка при принятии заявки:', error)
|
||||||
@ -78,7 +90,7 @@ export function MarketCounterparties() {
|
|||||||
const handleRejectRequest = async (requestId: string) => {
|
const handleRejectRequest = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
await respondToRequest({
|
await respondToRequest({
|
||||||
variables: { requestId, response: 'REJECTED' }
|
variables: { requestId, accept: false }
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при отклонении заявки:', error)
|
console.error('Ошибка при отклонении заявки:', error)
|
||||||
@ -207,50 +219,72 @@ export function MarketCounterparties() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{incomingRequests.map((request: CounterpartyRequest) => (
|
{incomingRequests.map((request: CounterpartyRequest) => (
|
||||||
<Card key={request.id} className="glass-card p-4">
|
<div key={request.id} className="glass-card p-4 w-full">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col space-y-4">
|
||||||
<div className="flex items-start space-x-3 flex-1">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="flex items-center space-x-3">
|
<OrganizationAvatar organization={request.sender} size="md" />
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
|
||||||
{(request.sender.name || request.sender.fullName || 'O').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-white font-medium">
|
<div className="flex flex-col space-y-2 mb-3">
|
||||||
{request.sender.name || request.sender.fullName}
|
<h4 className="text-white font-medium text-lg leading-tight">
|
||||||
</h4>
|
{request.sender.name || request.sender.fullName}
|
||||||
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
|
</h4>
|
||||||
{request.message && (
|
<div className="flex items-center space-x-3">
|
||||||
<p className="text-white/80 text-sm mt-2 italic">"{request.message}"</p>
|
<Badge className={
|
||||||
)}
|
request.sender.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' :
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
request.sender.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
|
||||||
<Clock className="h-3 w-3 text-white/40" />
|
request.sender.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
|
||||||
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
|
request.sender.type === 'WHOLESALE' ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' :
|
||||||
|
'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||||
|
}>
|
||||||
|
{request.sender.type === 'FULFILLMENT' ? 'Фулфилмент' :
|
||||||
|
request.sender.type === 'SELLER' ? 'Селлер' :
|
||||||
|
request.sender.type === 'LOGIST' ? 'Логистика' :
|
||||||
|
request.sender.type === 'WHOLESALE' ? 'Оптовик' :
|
||||||
|
request.sender.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
|
||||||
|
{request.sender.address && (
|
||||||
|
<div className="flex items-center text-white/60 text-sm">
|
||||||
|
<span className="truncate">{request.sender.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{request.message && (
|
||||||
|
<div className="p-2 bg-white/5 rounded border border-white/10">
|
||||||
|
<p className="text-white/80 text-sm italic">"{request.message}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center text-white/40 text-xs">
|
||||||
|
<span>Заявка от {formatDate(request.createdAt)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-2 ml-4">
|
<div className="flex space-x-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleAcceptRequest(request.id)}
|
onClick={() => handleAcceptRequest(request.id)}
|
||||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30 cursor-pointer"
|
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30 cursor-pointer flex-1"
|
||||||
>
|
>
|
||||||
<CheckCircle className="h-4 w-4" />
|
Принять
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleRejectRequest(request.id)}
|
onClick={() => handleRejectRequest(request.id)}
|
||||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer"
|
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer flex-1"
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4" />
|
Отклонить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -269,36 +303,56 @@ export function MarketCounterparties() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{outgoingRequests.map((request: CounterpartyRequest) => (
|
{outgoingRequests.map((request: CounterpartyRequest) => (
|
||||||
<Card key={request.id} className="glass-card p-4">
|
<div key={request.id} className="glass-card p-4 w-full">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col space-y-4">
|
||||||
<div className="flex items-start space-x-3 flex-1">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="flex items-center space-x-3">
|
<OrganizationAvatar organization={request.receiver} size="md" />
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
|
||||||
{(request.receiver.name || request.receiver.fullName || 'O').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-white font-medium">
|
<div className="flex flex-col space-y-2 mb-3">
|
||||||
{request.receiver.name || request.receiver.fullName}
|
<h4 className="text-white font-medium text-lg leading-tight">
|
||||||
</h4>
|
{request.receiver.name || request.receiver.fullName}
|
||||||
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
|
</h4>
|
||||||
{request.message && (
|
<div className="flex items-center space-x-3">
|
||||||
<p className="text-white/80 text-sm mt-2 italic">"{request.message}"</p>
|
<Badge className={
|
||||||
)}
|
request.receiver.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' :
|
||||||
<div className="flex items-center space-x-4 mt-2">
|
request.receiver.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
|
||||||
<div className="flex items-center space-x-2">
|
request.receiver.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
|
||||||
<Clock className="h-3 w-3 text-white/40" />
|
request.receiver.type === 'WHOLESALE' ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' :
|
||||||
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
|
'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||||
|
}>
|
||||||
|
{request.receiver.type === 'FULFILLMENT' ? 'Фулфилмент' :
|
||||||
|
request.receiver.type === 'SELLER' ? 'Селлер' :
|
||||||
|
request.receiver.type === 'LOGIST' ? 'Логистика' :
|
||||||
|
request.receiver.type === 'WHOLESALE' ? 'Оптовик' :
|
||||||
|
request.receiver.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={
|
||||||
|
request.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' :
|
||||||
|
request.status === 'REJECTED' ? 'bg-red-500/20 text-red-300 border-red-500/30' :
|
||||||
|
'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||||
|
}>
|
||||||
|
{request.status === 'PENDING' ? 'Ожидает ответа' : request.status === 'REJECTED' ? 'Отклонено' : request.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
|
||||||
|
{request.receiver.address && (
|
||||||
|
<div className="flex items-center text-white/60 text-sm">
|
||||||
|
<span className="truncate">{request.receiver.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{request.message && (
|
||||||
|
<div className="p-2 bg-white/5 rounded border border-white/10">
|
||||||
|
<p className="text-white/80 text-sm italic">"{request.message}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center text-white/40 text-xs">
|
||||||
|
<span>Отправлено {formatDate(request.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={
|
|
||||||
request.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' :
|
|
||||||
request.status === 'REJECTED' ? 'bg-red-500/20 text-red-300 border-red-500/30' :
|
|
||||||
'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
|
||||||
}>
|
|
||||||
{request.status === 'PENDING' ? 'Ожидает' : request.status === 'REJECTED' ? 'Отклонено' : request.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -308,13 +362,13 @@ export function MarketCounterparties() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleCancelRequest(request.id)}
|
onClick={() => handleCancelRequest(request.id)}
|
||||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer ml-4"
|
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer w-full"
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4" />
|
Отменить заявку
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Search, Package } from 'lucide-react'
|
import { Search, Package } from 'lucide-react'
|
||||||
import { OrganizationCard } from './organization-card'
|
import { OrganizationCard } from './organization-card'
|
||||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
@ -21,6 +21,9 @@ interface Organization {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
users?: Array<{ id: string, avatar?: string }>
|
users?: Array<{ id: string, avatar?: string }>
|
||||||
isCounterparty?: boolean
|
isCounterparty?: boolean
|
||||||
|
isCurrentUser?: boolean
|
||||||
|
hasOutgoingRequest?: boolean
|
||||||
|
hasIncomingRequest?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarketFulfillment() {
|
export function MarketFulfillment() {
|
||||||
@ -31,9 +34,15 @@ export function MarketFulfillment() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||||
onCompleted: () => {
|
refetchQueries: [
|
||||||
refetch()
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||||
}
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
||||||
|
{ query: GET_OUTGOING_REQUESTS },
|
||||||
|
{ query: GET_INCOMING_REQUESTS }
|
||||||
|
],
|
||||||
|
awaitRefetchQueries: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
@ -44,7 +53,7 @@ export function MarketFulfillment() {
|
|||||||
try {
|
try {
|
||||||
await sendRequest({
|
await sendRequest({
|
||||||
variables: {
|
variables: {
|
||||||
receiverId: organizationId,
|
organizationId: organizationId,
|
||||||
message: message || 'Заявка на добавление в контрагенты'
|
message: message || 'Заявка на добавление в контрагенты'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Search, Truck } from 'lucide-react'
|
import { Search, Truck } from 'lucide-react'
|
||||||
import { OrganizationCard } from './organization-card'
|
import { OrganizationCard } from './organization-card'
|
||||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
@ -21,6 +21,9 @@ interface Organization {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
users?: Array<{ id: string, avatar?: string }>
|
users?: Array<{ id: string, avatar?: string }>
|
||||||
isCounterparty?: boolean
|
isCounterparty?: boolean
|
||||||
|
isCurrentUser?: boolean
|
||||||
|
hasOutgoingRequest?: boolean
|
||||||
|
hasIncomingRequest?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarketLogistics() {
|
export function MarketLogistics() {
|
||||||
@ -31,9 +34,15 @@ export function MarketLogistics() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||||
onCompleted: () => {
|
refetchQueries: [
|
||||||
refetch()
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||||
}
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
||||||
|
{ query: GET_OUTGOING_REQUESTS },
|
||||||
|
{ query: GET_INCOMING_REQUESTS }
|
||||||
|
],
|
||||||
|
awaitRefetchQueries: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
@ -44,7 +53,7 @@ export function MarketLogistics() {
|
|||||||
try {
|
try {
|
||||||
await sendRequest({
|
await sendRequest({
|
||||||
variables: {
|
variables: {
|
||||||
receiverId: organizationId,
|
organizationId: organizationId,
|
||||||
message: message || 'Заявка на добавление в контрагенты'
|
message: message || 'Заявка на добавление в контрагенты'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Search, ShoppingCart } from 'lucide-react'
|
import { Search, ShoppingCart } from 'lucide-react'
|
||||||
import { OrganizationCard } from './organization-card'
|
import { OrganizationCard } from './organization-card'
|
||||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
@ -21,6 +21,9 @@ interface Organization {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
users?: Array<{ id: string, avatar?: string }>
|
users?: Array<{ id: string, avatar?: string }>
|
||||||
isCounterparty?: boolean
|
isCounterparty?: boolean
|
||||||
|
isCurrentUser?: boolean
|
||||||
|
hasOutgoingRequest?: boolean
|
||||||
|
hasIncomingRequest?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarketSellers() {
|
export function MarketSellers() {
|
||||||
@ -31,9 +34,15 @@ export function MarketSellers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||||
onCompleted: () => {
|
refetchQueries: [
|
||||||
refetch()
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||||
}
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
||||||
|
{ query: GET_OUTGOING_REQUESTS },
|
||||||
|
{ query: GET_INCOMING_REQUESTS }
|
||||||
|
],
|
||||||
|
awaitRefetchQueries: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
@ -44,7 +53,7 @@ export function MarketSellers() {
|
|||||||
try {
|
try {
|
||||||
await sendRequest({
|
await sendRequest({
|
||||||
variables: {
|
variables: {
|
||||||
receiverId: organizationId,
|
organizationId: organizationId,
|
||||||
message: message || 'Заявка на добавление в контрагенты'
|
message: message || 'Заявка на добавление в контрагенты'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Search, Boxes } from 'lucide-react'
|
import { Search, Boxes } from 'lucide-react'
|
||||||
import { OrganizationCard } from './organization-card'
|
import { OrganizationCard } from './organization-card'
|
||||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
@ -21,6 +21,9 @@ interface Organization {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
users?: Array<{ id: string, avatar?: string }>
|
users?: Array<{ id: string, avatar?: string }>
|
||||||
isCounterparty?: boolean
|
isCounterparty?: boolean
|
||||||
|
isCurrentUser?: boolean
|
||||||
|
hasOutgoingRequest?: boolean
|
||||||
|
hasIncomingRequest?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarketWholesale() {
|
export function MarketWholesale() {
|
||||||
@ -31,9 +34,15 @@ export function MarketWholesale() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||||
onCompleted: () => {
|
refetchQueries: [
|
||||||
refetch()
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
|
||||||
}
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
|
||||||
|
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
|
||||||
|
{ query: GET_OUTGOING_REQUESTS },
|
||||||
|
{ query: GET_INCOMING_REQUESTS }
|
||||||
|
],
|
||||||
|
awaitRefetchQueries: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
@ -44,7 +53,7 @@ export function MarketWholesale() {
|
|||||||
try {
|
try {
|
||||||
await sendRequest({
|
await sendRequest({
|
||||||
variables: {
|
variables: {
|
||||||
receiverId: organizationId,
|
organizationId: organizationId,
|
||||||
message: message || 'Заявка на добавление в контрагенты'
|
message: message || 'Заявка на добавление в контрагенты'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -11,7 +11,8 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Plus,
|
Plus,
|
||||||
Send,
|
Send,
|
||||||
Trash2
|
Trash2,
|
||||||
|
User
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { OrganizationAvatar } from './organization-avatar'
|
import { OrganizationAvatar } from './organization-avatar'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@ -28,6 +29,9 @@ interface Organization {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
users?: Array<{ id: string, avatar?: string }>
|
users?: Array<{ id: string, avatar?: string }>
|
||||||
isCounterparty?: boolean
|
isCounterparty?: boolean
|
||||||
|
isCurrentUser?: boolean
|
||||||
|
hasOutgoingRequest?: boolean
|
||||||
|
hasIncomingRequest?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrganizationCardProps {
|
interface OrganizationCardProps {
|
||||||
@ -144,7 +148,12 @@ export function OrganizationCard({
|
|||||||
<Badge className={getTypeColor(organization.type)}>
|
<Badge className={getTypeColor(organization.type)}>
|
||||||
{getTypeLabel(organization.type)}
|
{getTypeLabel(organization.type)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{organization.isCounterparty && (
|
{organization.isCurrentUser && (
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30">
|
||||||
|
Это вы
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{organization.isCounterparty && !organization.isCurrentUser && (
|
||||||
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
|
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
|
||||||
Уже добавлен
|
Уже добавлен
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -190,16 +199,29 @@ export function OrganizationCard({
|
|||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Удалить из контрагентов
|
Удалить из контрагентов
|
||||||
</Button>
|
</Button>
|
||||||
|
) : organization.isCurrentUser ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
className="bg-blue-500/10 text-blue-300 border-blue-500/30 w-full opacity-50"
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4 mr-2" />
|
||||||
|
Ваша организация
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={organization.isCounterparty}
|
disabled={organization.isCounterparty || organization.hasOutgoingRequest || organization.hasIncomingRequest}
|
||||||
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty)} w-full cursor-pointer`}
|
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty || !!organization.hasOutgoingRequest || !!organization.hasIncomingRequest)} w-full cursor-pointer`}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{organization.isCounterparty ? 'Уже добавлен' : actionButtonText}
|
{organization.isCounterparty ? 'Уже добавлен' :
|
||||||
|
organization.hasOutgoingRequest ? 'Заявка отправлена' :
|
||||||
|
organization.hasIncomingRequest ? 'Уже подал заявку' :
|
||||||
|
actionButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
|
432
src/components/messenger/messenger-chat.tsx
Normal file
432
src/components/messenger/messenger-chat.tsx
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { useMutation, useQuery } from '@apollo/client'
|
||||||
|
import { GET_MESSAGES } from '@/graphql/queries'
|
||||||
|
import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE } from '@/graphql/mutations'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { EmojiPickerComponent } from '@/components/ui/emoji-picker'
|
||||||
|
import { VoiceRecorder } from '@/components/ui/voice-recorder'
|
||||||
|
import { VoicePlayer } from '@/components/ui/voice-player'
|
||||||
|
import { FileUploader } from '@/components/ui/file-uploader'
|
||||||
|
import { ImageMessage } from '@/components/ui/image-message'
|
||||||
|
import { FileMessage } from '@/components/ui/file-message'
|
||||||
|
import { Send, MoreVertical } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
id: string
|
||||||
|
inn: string
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||||
|
address?: string
|
||||||
|
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
content?: string
|
||||||
|
type?: 'TEXT' | 'VOICE' | 'IMAGE' | 'FILE' | null
|
||||||
|
voiceUrl?: string
|
||||||
|
voiceDuration?: number
|
||||||
|
fileUrl?: string
|
||||||
|
fileName?: string
|
||||||
|
fileSize?: number
|
||||||
|
fileType?: string
|
||||||
|
senderId: string
|
||||||
|
senderOrganization: Organization
|
||||||
|
receiverOrganization: Organization
|
||||||
|
createdAt: string
|
||||||
|
isRead: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessengerChatProps {
|
||||||
|
counterparty: Organization
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessengerChat({ counterparty }: MessengerChatProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Загружаем сообщения с контрагентом
|
||||||
|
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||||||
|
variables: { counterpartyId: counterparty.id },
|
||||||
|
pollInterval: 3000, // Обновляем каждые 3 секунды для получения новых сообщений
|
||||||
|
fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные
|
||||||
|
errorPolicy: 'all' // Показываем данные даже при ошибках
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetch() // Перезагружаем сообщения после отправки
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Ошибка отправки сообщения:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sendVoiceMessageMutation] = useMutation(SEND_VOICE_MESSAGE, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetch() // Перезагружаем сообщения после отправки
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Ошибка отправки голосового сообщения:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sendImageMessageMutation] = useMutation(SEND_IMAGE_MESSAGE, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetch()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Ошибка отправки изображения:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sendFileMessageMutation] = useMutation(SEND_FILE_MESSAGE, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetch()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Ошибка отправки файла:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = messagesData?.messages || []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const getOrganizationName = (org: Organization) => {
|
||||||
|
return org.name || org.fullName || 'Организация'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getManagerName = (org: Organization) => {
|
||||||
|
return org.users?.[0]?.managerName || 'Управляющий'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (org: Organization) => {
|
||||||
|
const name = getOrganizationName(org)
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'FULFILLMENT':
|
||||||
|
return 'Фулфилмент'
|
||||||
|
case 'SELLER':
|
||||||
|
return 'Селлер'
|
||||||
|
case 'LOGIST':
|
||||||
|
return 'Логистика'
|
||||||
|
case 'WHOLESALE':
|
||||||
|
return 'Оптовик'
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'FULFILLMENT':
|
||||||
|
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||||
|
case 'SELLER':
|
||||||
|
return 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||||
|
case 'LOGIST':
|
||||||
|
return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
|
||||||
|
case 'WHOLESALE':
|
||||||
|
return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!message.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMessageMutation({
|
||||||
|
variables: {
|
||||||
|
receiverOrganizationId: counterparty.id,
|
||||||
|
content: message.trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setMessage('')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки сообщения:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSendMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmojiSelect = (emoji: string) => {
|
||||||
|
setMessage(prev => prev + emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendVoice = async (audioUrl: string, duration: number) => {
|
||||||
|
try {
|
||||||
|
await sendVoiceMessageMutation({
|
||||||
|
variables: {
|
||||||
|
receiverOrganizationId: counterparty.id,
|
||||||
|
voiceUrl: audioUrl,
|
||||||
|
voiceDuration: duration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки голосового сообщения:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendFile = async (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => {
|
||||||
|
try {
|
||||||
|
if (messageType === 'IMAGE') {
|
||||||
|
await sendImageMessageMutation({
|
||||||
|
variables: {
|
||||||
|
receiverOrganizationId: counterparty.id,
|
||||||
|
fileUrl,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
fileType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await sendFileMessageMutation({
|
||||||
|
variables: {
|
||||||
|
receiverOrganizationId: counterparty.id,
|
||||||
|
fileUrl,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
fileType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки файла:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return '00:00'
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return 'Неизвестная дата'
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return 'Сегодня'
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Вчера'
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Заголовок чата */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
{counterparty.users?.[0]?.avatar ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={counterparty.users[0].avatar}
|
||||||
|
alt="Аватар организации"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
||||||
|
{getInitials(counterparty)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium">
|
||||||
|
{getOrganizationName(counterparty)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/60 text-sm mb-1">
|
||||||
|
{getManagerName(counterparty)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge className={`${getTypeColor(counterparty.type)} text-xs`}>
|
||||||
|
{getTypeLabel(counterparty.type)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" className="text-white/60 hover:text-white">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Область сообщений */}
|
||||||
|
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-white/60">Загрузка сообщений...</div>
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Send className="h-6 w-6 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/60 text-sm mb-1">Начните беседу</p>
|
||||||
|
<p className="text-white/40 text-xs">
|
||||||
|
Отправьте первое сообщение {getOrganizationName(counterparty)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg: Message, index: number) => {
|
||||||
|
const isCurrentUser = msg.senderOrganization?.id === user?.organization?.id
|
||||||
|
const showDate = index === 0 ||
|
||||||
|
formatDate(messages[index - 1].createdAt) !== formatDate(msg.createdAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.id}>
|
||||||
|
{showDate && (
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="bg-white/10 rounded-full px-3 py-1">
|
||||||
|
<span className="text-white/60 text-xs">
|
||||||
|
{formatDate(msg.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
||||||
|
<div className="flex flex-col max-w-xs lg:max-w-md">
|
||||||
|
{/* Имя отправителя */}
|
||||||
|
{!isCurrentUser && (
|
||||||
|
<div className="flex items-center space-x-2 mb-1 px-1">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
{msg.senderOrganization?.users?.[0]?.avatar ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={msg.senderOrganization.users[0].avatar}
|
||||||
|
alt="Аватар отправителя"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-purple-500 text-white text-xs">
|
||||||
|
{getInitials(msg.senderOrganization)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-white/60 text-xs">
|
||||||
|
{getManagerName(msg.senderOrganization)}
|
||||||
|
</span>
|
||||||
|
<Badge className={`${getTypeColor(msg.senderOrganization.type)} text-xs`}>
|
||||||
|
{getTypeLabel(msg.senderOrganization.type)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Сообщение */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{msg.type === 'VOICE' && msg.voiceUrl ? (
|
||||||
|
<VoicePlayer
|
||||||
|
audioUrl={msg.voiceUrl}
|
||||||
|
duration={msg.voiceDuration || 0}
|
||||||
|
isCurrentUser={isCurrentUser}
|
||||||
|
/>
|
||||||
|
) : msg.type === 'IMAGE' && msg.fileUrl ? (
|
||||||
|
<ImageMessage
|
||||||
|
imageUrl={msg.fileUrl}
|
||||||
|
fileName={msg.fileName || 'image'}
|
||||||
|
fileSize={msg.fileSize}
|
||||||
|
isCurrentUser={isCurrentUser}
|
||||||
|
/>
|
||||||
|
) : msg.type === 'FILE' && msg.fileUrl ? (
|
||||||
|
<FileMessage
|
||||||
|
fileUrl={msg.fileUrl}
|
||||||
|
fileName={msg.fileName || 'file'}
|
||||||
|
fileSize={msg.fileSize}
|
||||||
|
fileType={msg.fileType}
|
||||||
|
isCurrentUser={isCurrentUser}
|
||||||
|
/>
|
||||||
|
) : msg.content ? (
|
||||||
|
<div className={`px-4 py-2 rounded-lg ${
|
||||||
|
isCurrentUser
|
||||||
|
? 'bg-blue-500/20 text-white border border-blue-500/30'
|
||||||
|
: 'bg-white/10 text-white border border-white/20'
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm leading-relaxed">{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className={`text-xs ${
|
||||||
|
isCurrentUser ? 'text-blue-300/70' : 'text-white/50'
|
||||||
|
}`}>
|
||||||
|
{formatTime(msg.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поле ввода сообщения */}
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<div className="flex flex-1 space-x-2">
|
||||||
|
<Input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
placeholder="Введите сообщение..."
|
||||||
|
className="glass-input text-white placeholder:text-white/40 flex-1"
|
||||||
|
/>
|
||||||
|
<EmojiPickerComponent onEmojiSelect={handleEmojiSelect} />
|
||||||
|
<FileUploader onSendFile={handleSendFile} />
|
||||||
|
<VoiceRecorder onSendVoice={handleSendVoice} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!message.trim()}
|
||||||
|
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
178
src/components/messenger/messenger-conversations.tsx
Normal file
178
src/components/messenger/messenger-conversations.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Users, Search } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
id: string
|
||||||
|
inn: string
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
managementName?: string
|
||||||
|
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||||
|
address?: string
|
||||||
|
phones?: Array<{ value: string }>
|
||||||
|
emails?: Array<{ value: string }>
|
||||||
|
users?: Array<{ id: string, avatar?: string, managerName?: string }>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessengerConversationsProps {
|
||||||
|
counterparties: Organization[]
|
||||||
|
loading: boolean
|
||||||
|
selectedCounterparty: string | null
|
||||||
|
onSelectCounterparty: (counterpartyId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessengerConversations({
|
||||||
|
counterparties,
|
||||||
|
loading,
|
||||||
|
selectedCounterparty,
|
||||||
|
onSelectCounterparty
|
||||||
|
}: MessengerConversationsProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
|
const getOrganizationName = (org: Organization) => {
|
||||||
|
return org.name || org.fullName || 'Организация'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getManagerName = (org: Organization) => {
|
||||||
|
return org.users?.[0]?.managerName || 'Управляющий'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (org: Organization) => {
|
||||||
|
const name = getOrganizationName(org)
|
||||||
|
return name.charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'FULFILLMENT':
|
||||||
|
return 'Фулфилмент'
|
||||||
|
case 'SELLER':
|
||||||
|
return 'Селлер'
|
||||||
|
case 'LOGIST':
|
||||||
|
return 'Логистика'
|
||||||
|
case 'WHOLESALE':
|
||||||
|
return 'Оптовик'
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'FULFILLMENT':
|
||||||
|
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||||
|
case 'SELLER':
|
||||||
|
return 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||||
|
case 'LOGIST':
|
||||||
|
return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
|
||||||
|
case 'WHOLESALE':
|
||||||
|
return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCounterparties = counterparties.filter(org => {
|
||||||
|
if (!searchTerm) return true
|
||||||
|
const name = getOrganizationName(org).toLowerCase()
|
||||||
|
const managerName = getManagerName(org).toLowerCase()
|
||||||
|
const inn = org.inn.toLowerCase()
|
||||||
|
const search = searchTerm.toLowerCase()
|
||||||
|
return name.includes(search) || inn.includes(search) || managerName.includes(search)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="text-white/60">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<Users className="h-5 w-5 text-blue-400" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
|
||||||
|
<p className="text-white/60 text-sm">{counterparties.length} активных</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поиск */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по названию или ИНН..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="glass-input text-white placeholder:text-white/40 pl-10 h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список контрагентов */}
|
||||||
|
<div className="flex-1 overflow-auto space-y-2">
|
||||||
|
{filteredCounterparties.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Users className="h-6 w-6 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
{searchTerm ? 'Ничего не найдено' : 'Контрагенты не найдены'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredCounterparties.map((org) => (
|
||||||
|
<div
|
||||||
|
key={org.id}
|
||||||
|
onClick={() => onSelectCounterparty(org.id)}
|
||||||
|
className={`p-3 rounded-lg cursor-pointer transition-all duration-200 ${
|
||||||
|
selectedCounterparty === org.id
|
||||||
|
? 'bg-white/20 border border-white/30'
|
||||||
|
: 'bg-white/5 hover:bg-white/10 border border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Avatar className="h-10 w-10 flex-shrink-0">
|
||||||
|
{org.users?.[0]?.avatar ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={org.users[0].avatar}
|
||||||
|
alt="Аватар организации"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
||||||
|
{getInitials(org)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-white font-medium text-sm leading-tight truncate mb-1">
|
||||||
|
{getOrganizationName(org)}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
|
<Badge className={`${getTypeColor(org.type)} text-xs`}>
|
||||||
|
{getTypeLabel(org.type)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-white/60 text-xs truncate">
|
||||||
|
{getManagerName(org)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
113
src/components/messenger/messenger-dashboard.tsx
Normal file
113
src/components/messenger/messenger-dashboard.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
|
import { MessengerConversations } from './messenger-conversations'
|
||||||
|
import { MessengerChat } from './messenger-chat'
|
||||||
|
import { MessengerEmptyState } from './messenger-empty-state'
|
||||||
|
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||||
|
import { MessageCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
id: string
|
||||||
|
inn: string
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||||
|
address?: string
|
||||||
|
phones?: Array<{ value: string }>
|
||||||
|
emails?: Array<{ value: string }>
|
||||||
|
users?: Array<{ id: string, avatar?: string }>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessengerDashboard() {
|
||||||
|
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
||||||
|
const counterparties = counterpartiesData?.myCounterparties || []
|
||||||
|
|
||||||
|
const handleSelectCounterparty = (counterpartyId: string) => {
|
||||||
|
setSelectedCounterparty(counterpartyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCounterpartyData = counterparties.find((cp: Organization) => cp.id === selectedCounterparty)
|
||||||
|
|
||||||
|
// Если нет контрагентов, показываем заглушку
|
||||||
|
if (!counterpartiesLoading && counterparties.length === 0) {
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<Card className="glass-card h-full overflow-hidden p-6">
|
||||||
|
<MessengerEmptyState />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Основной контент - сетка из 2 колонок */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[350px_1fr] gap-4 h-full">
|
||||||
|
{/* Левая колонка - список контрагентов */}
|
||||||
|
<Card className="glass-card h-full overflow-hidden p-4">
|
||||||
|
<MessengerConversations
|
||||||
|
counterparties={counterparties}
|
||||||
|
loading={counterpartiesLoading}
|
||||||
|
selectedCounterparty={selectedCounterparty}
|
||||||
|
onSelectCounterparty={handleSelectCounterparty}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Правая колонка - чат */}
|
||||||
|
<Card className="glass-card h-full overflow-hidden">
|
||||||
|
{selectedCounterparty && selectedCounterpartyData ? (
|
||||||
|
<MessengerChat counterparty={selectedCounterpartyData} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<MessageCircle className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/60 text-lg mb-2">Выберите контрагента</p>
|
||||||
|
<p className="text-white/40 text-sm">
|
||||||
|
Начните беседу с одним из ваших контрагентов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
48
src/components/messenger/messenger-empty-state.tsx
Normal file
48
src/components/messenger/messenger-empty-state.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { MessageCircle, Store, Users } from 'lucide-react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export function MessengerEmptyState() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleGoToMarket = () => {
|
||||||
|
router.push('/market')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<MessageCircle className="h-10 w-10 text-white/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-3">
|
||||||
|
У вас пока нет контрагентов
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-white/60 text-sm mb-6 leading-relaxed">
|
||||||
|
Чтобы начать общение, сначала найдите и добавьте контрагентов в разделе «Маркет».
|
||||||
|
После добавления они появятся здесь для общения.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleGoToMarket}
|
||||||
|
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 w-full cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Store className="h-4 w-4 mr-2" />
|
||||||
|
Перейти в Маркет
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center text-white/40 text-xs">
|
||||||
|
<Users className="h-3 w-3 mr-1" />
|
||||||
|
Найдите партнеров и начните общение
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
68
src/components/ui/emoji-picker.tsx
Normal file
68
src/components/ui/emoji-picker.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Smile } from 'lucide-react'
|
||||||
|
|
||||||
|
// Динамически импортируем EmojiPicker чтобы избежать проблем с SSR
|
||||||
|
const EmojiPicker = dynamic(
|
||||||
|
() => import('emoji-picker-react'),
|
||||||
|
{ ssr: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
interface EmojiPickerComponentProps {
|
||||||
|
onEmojiSelect: (emoji: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiPickerComponent({ onEmojiSelect }: EmojiPickerComponentProps) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowPicker(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPicker) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [showPicker])
|
||||||
|
|
||||||
|
const handleEmojiClick = (emojiData: { emoji: string }) => {
|
||||||
|
onEmojiSelect(emojiData.emoji)
|
||||||
|
setShowPicker(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={pickerRef}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowPicker(!showPicker)}
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||||
|
>
|
||||||
|
<Smile className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showPicker && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 z-50">
|
||||||
|
<div className="bg-gray-800 border border-white/20 rounded-lg overflow-hidden shadow-xl">
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiClick={handleEmojiClick}
|
||||||
|
width={350}
|
||||||
|
height={400}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
108
src/components/ui/file-message.tsx
Normal file
108
src/components/ui/file-message.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Download, FileText, Archive, Image as ImageIcon } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface FileMessageProps {
|
||||||
|
fileUrl: string
|
||||||
|
fileName: string
|
||||||
|
fileSize?: number
|
||||||
|
fileType?: string
|
||||||
|
isCurrentUser?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileMessage({ fileUrl, fileName, fileSize, fileType, isCurrentUser = false }: FileMessageProps) {
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number) => {
|
||||||
|
if (!bytes) return ''
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileIcon = (type?: string) => {
|
||||||
|
if (!type) return <FileText className="h-6 w-6" />
|
||||||
|
|
||||||
|
if (type.includes('pdf')) return <FileText className="h-6 w-6 text-red-400" />
|
||||||
|
if (type.includes('zip') || type.includes('archive')) return <Archive className="h-6 w-6 text-yellow-400" />
|
||||||
|
if (type.includes('image')) return <ImageIcon className="h-6 w-6 text-green-400" />
|
||||||
|
if (type.includes('word') || type.includes('document')) return <FileText className="h-6 w-6 text-blue-400" />
|
||||||
|
if (type.includes('excel') || type.includes('spreadsheet')) return <FileText className="h-6 w-6 text-green-600" />
|
||||||
|
if (type.includes('powerpoint') || type.includes('presentation')) return <FileText className="h-6 w-6 text-orange-400" />
|
||||||
|
|
||||||
|
return <FileText className="h-6 w-6" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileExtension = (name: string) => {
|
||||||
|
const parts = name.split('.')
|
||||||
|
return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = fileUrl
|
||||||
|
link.download = fileName
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center space-x-3 p-3 rounded-lg max-w-sm cursor-pointer hover:opacity-80 transition-opacity ${
|
||||||
|
isCurrentUser
|
||||||
|
? 'bg-blue-500/20 border border-blue-500/30'
|
||||||
|
: 'bg-white/10 border border-white/20'
|
||||||
|
}`} onClick={handleDownload}>
|
||||||
|
|
||||||
|
{/* Иконка файла */}
|
||||||
|
<div className={`flex-shrink-0 w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||||
|
isCurrentUser ? 'bg-blue-500/30' : 'bg-white/20'
|
||||||
|
}`}>
|
||||||
|
{getFileIcon(fileType)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Информация о файле */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`font-medium text-sm truncate ${
|
||||||
|
isCurrentUser ? 'text-white' : 'text-white'
|
||||||
|
}`}>
|
||||||
|
{fileName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
{fileSize && (
|
||||||
|
<span className={`text-xs ${
|
||||||
|
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||||
|
}`}>
|
||||||
|
{formatFileSize(fileSize)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={`text-xs ${
|
||||||
|
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||||
|
}`}>
|
||||||
|
{getFileExtension(fileName)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка скачивания */}
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDownload()
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className={`flex-shrink-0 ${
|
||||||
|
isCurrentUser
|
||||||
|
? 'text-blue-300 hover:text-blue-200 hover:bg-blue-500/30'
|
||||||
|
: 'text-white/60 hover:text-white hover:bg-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
216
src/components/ui/file-uploader.tsx
Normal file
216
src/components/ui/file-uploader.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Paperclip, Image, X } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
|
interface FileUploaderProps {
|
||||||
|
onSendFile: (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadedFile {
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
messageType: 'IMAGE' | 'FILE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploader({ onSendFile }: FileUploaderProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
|
const [selectedFile, setSelectedFile] = useState<UploadedFile | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const isImageType = (type: string) => {
|
||||||
|
return type.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = async (file: File, messageType: 'IMAGE' | 'FILE') => {
|
||||||
|
if (!user?.id) return
|
||||||
|
|
||||||
|
setIsUploading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('userId', user.id)
|
||||||
|
formData.append('messageType', messageType)
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload-file', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = 'Failed to upload file'
|
||||||
|
try {
|
||||||
|
const errorData = await response.json()
|
||||||
|
errorMessage = errorData.error || errorMessage
|
||||||
|
} catch {
|
||||||
|
errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Upload failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile({
|
||||||
|
url: result.url,
|
||||||
|
name: result.originalName,
|
||||||
|
size: result.size,
|
||||||
|
type: result.type,
|
||||||
|
messageType: result.messageType
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||||
|
alert(`Ошибка при загрузке файла: ${errorMessage}`)
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>, messageType: 'IMAGE' | 'FILE') => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file, messageType)
|
||||||
|
}
|
||||||
|
// Очищаем input для возможности выбора того же файла
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendFile = () => {
|
||||||
|
if (selectedFile) {
|
||||||
|
onSendFile(
|
||||||
|
selectedFile.url,
|
||||||
|
selectedFile.name,
|
||||||
|
selectedFile.size,
|
||||||
|
selectedFile.type,
|
||||||
|
selectedFile.messageType
|
||||||
|
)
|
||||||
|
setSelectedFile(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelFile = () => {
|
||||||
|
setSelectedFile(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFile) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2 bg-white/10 rounded-lg p-3 border border-white/20">
|
||||||
|
<div className="flex items-center space-x-2 flex-1">
|
||||||
|
{isImageType(selectedFile.type) ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<img
|
||||||
|
src={selectedFile.url}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-10 h-10 object-cover rounded"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
|
||||||
|
<p className="text-white/60 text-xs">{formatFileSize(selectedFile.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-10 h-10 bg-blue-500/20 rounded flex items-center justify-center">
|
||||||
|
<Paperclip className="h-5 w-5 text-blue-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
|
||||||
|
<p className="text-white/60 text-xs">{formatFileSize(selectedFile.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Button
|
||||||
|
onClick={handleSendFile}
|
||||||
|
size="sm"
|
||||||
|
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Отправить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelFile}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* Кнопка для загрузки изображений */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => imageInputRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||||
|
>
|
||||||
|
<Image className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Кнопка для загрузки файлов */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Скрытые input элементы */}
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => handleFileInputChange(e, 'IMAGE')}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.json"
|
||||||
|
onChange={(e) => handleFileInputChange(e, 'FILE')}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div className="text-white/60 text-xs">
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
129
src/components/ui/image-message.tsx
Normal file
129
src/components/ui/image-message.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Download, Eye } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface ImageMessageProps {
|
||||||
|
imageUrl: string
|
||||||
|
fileName: string
|
||||||
|
fileSize?: number
|
||||||
|
isCurrentUser?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = false }: ImageMessageProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [showFullSize, setShowFullSize] = useState(false)
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number) => {
|
||||||
|
if (!bytes) return ''
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = imageUrl
|
||||||
|
link.download = fileName
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageClick = () => {
|
||||||
|
setShowFullSize(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`relative group max-w-xs ${
|
||||||
|
isCurrentUser
|
||||||
|
? 'bg-blue-500/20 border border-blue-500/30'
|
||||||
|
: 'bg-white/10 border border-white/20'
|
||||||
|
} rounded-lg overflow-hidden`}>
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={fileName}
|
||||||
|
className="w-full h-auto cursor-pointer transition-opacity duration-200"
|
||||||
|
style={{
|
||||||
|
opacity: isLoading ? 0 : 1,
|
||||||
|
maxHeight: '300px',
|
||||||
|
objectFit: 'cover'
|
||||||
|
}}
|
||||||
|
onLoad={() => setIsLoading(false)}
|
||||||
|
onClick={handleImageClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 bg-gray-700 animate-pulse flex items-center justify-center">
|
||||||
|
<div className="text-white/60 text-sm">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay с кнопками при наведении */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center space-x-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleImageClick}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-white hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-white hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Информация о файле */}
|
||||||
|
<div className="p-2">
|
||||||
|
<p className={`text-xs ${
|
||||||
|
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
|
||||||
|
} truncate`}>
|
||||||
|
{fileName}
|
||||||
|
</p>
|
||||||
|
{fileSize && (
|
||||||
|
<p className={`text-xs ${
|
||||||
|
isCurrentUser ? 'text-blue-300/50' : 'text-white/40'
|
||||||
|
}`}>
|
||||||
|
{formatFileSize(fileSize)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Полноэкранный просмотр */}
|
||||||
|
{showFullSize && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setShowFullSize(false)}
|
||||||
|
>
|
||||||
|
<div className="relative max-w-full max-h-full">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={fileName}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowFullSize(false)}
|
||||||
|
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
174
src/components/ui/voice-player.tsx
Normal file
174
src/components/ui/voice-player.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Play, Pause, Volume2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface VoicePlayerProps {
|
||||||
|
audioUrl: string
|
||||||
|
duration?: number
|
||||||
|
isCurrentUser?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoicePlayer({ audioUrl, duration = 0, isCurrentUser = false }: VoicePlayerProps) {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [audioDuration, setAudioDuration] = useState(duration > 0 ? duration : 0)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
// Обновляем длительность при изменении props
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration > 0 && (!audioDuration || audioDuration === 0)) {
|
||||||
|
setAudioDuration(duration)
|
||||||
|
}
|
||||||
|
}, [duration, audioDuration])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Создаем аудио элемент
|
||||||
|
audioRef.current = new Audio(audioUrl)
|
||||||
|
const audio = audioRef.current
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (audio.duration && isFinite(audio.duration) && !isNaN(audio.duration)) {
|
||||||
|
setAudioDuration(audio.duration)
|
||||||
|
} else {
|
||||||
|
setAudioDuration(duration || 0)
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (audio.currentTime && isFinite(audio.currentTime) && !isNaN(audio.currentTime)) {
|
||||||
|
setCurrentTime(audio.currentTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
setCurrentTime(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCanPlay = () => {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadStart = () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
console.error('Audio loading error')
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsPlaying(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate)
|
||||||
|
audio.addEventListener('ended', handleEnded)
|
||||||
|
audio.addEventListener('canplay', handleCanPlay)
|
||||||
|
audio.addEventListener('loadstart', handleLoadStart)
|
||||||
|
audio.addEventListener('error', handleError)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (audio) {
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate)
|
||||||
|
audio.removeEventListener('ended', handleEnded)
|
||||||
|
audio.removeEventListener('canplay', handleCanPlay)
|
||||||
|
audio.removeEventListener('loadstart', handleLoadStart)
|
||||||
|
audio.removeEventListener('error', handleError)
|
||||||
|
audio.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [audioUrl])
|
||||||
|
|
||||||
|
const togglePlayPause = () => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audio.pause()
|
||||||
|
setIsPlaying(false)
|
||||||
|
} else {
|
||||||
|
audio.play().then(() => {
|
||||||
|
setIsPlaying(true)
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error playing audio:', error)
|
||||||
|
setIsPlaying(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time: number) => {
|
||||||
|
if (isNaN(time) || !isFinite(time) || time < 0) return '0:00'
|
||||||
|
const minutes = Math.floor(time / 60)
|
||||||
|
const seconds = Math.floor(time % 60)
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProgress = () => {
|
||||||
|
if (!audioDuration || audioDuration === 0 || isNaN(audioDuration) || !isFinite(audioDuration)) return 0
|
||||||
|
if (!currentTime || isNaN(currentTime) || !isFinite(currentTime)) return 0
|
||||||
|
return Math.min((currentTime / audioDuration) * 100, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center space-x-4 p-3 rounded-lg min-w-[200px] max-w-sm ${
|
||||||
|
isCurrentUser
|
||||||
|
? 'bg-blue-500/20 border border-blue-500/30'
|
||||||
|
: 'bg-white/10 border border-white/20'
|
||||||
|
}`}>
|
||||||
|
{/* Кнопка воспроизведения */}
|
||||||
|
<Button
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={`p-2 rounded-full ${
|
||||||
|
isCurrentUser
|
||||||
|
? 'text-blue-300 hover:text-blue-200 hover:bg-blue-500/30'
|
||||||
|
: 'text-white hover:text-white/80 hover:bg-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : isPlaying ? (
|
||||||
|
<Pause className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Визуализация волны / прогресс бар */}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Volume2 className={`h-3 w-3 ${
|
||||||
|
isCurrentUser ? 'text-blue-300' : 'text-white/60'
|
||||||
|
}`} />
|
||||||
|
<div className="flex-1 h-1 bg-white/20 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${
|
||||||
|
isCurrentUser ? 'bg-blue-400' : 'bg-white/60'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${getProgress()}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Время */}
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className={`${isCurrentUser ? 'text-blue-300/70' : 'text-white/50'} min-w-[2rem]`}>
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</span>
|
||||||
|
<span className={`${isCurrentUser ? 'text-blue-300/70' : 'text-white/50'} min-w-[2rem] text-right`}>
|
||||||
|
{formatTime(audioDuration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
254
src/components/ui/voice-recorder.tsx
Normal file
254
src/components/ui/voice-recorder.tsx
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Mic, MicOff, Square, Send, Trash2 } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
|
||||||
|
interface VoiceRecorderProps {
|
||||||
|
onSendVoice: (audioUrl: string, duration: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [recordedAudio, setRecordedAudio] = useState<string | null>(null)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [permission, setPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt')
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const audioChunksRef = useRef<Blob[]>([])
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Проверяем доступность микрофона
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
|
||||||
|
navigator.permissions.query({ name: 'microphone' as PermissionName }).then((result) => {
|
||||||
|
setPermission(result.state as 'granted' | 'denied' | 'prompt')
|
||||||
|
}).catch(() => {
|
||||||
|
// Если permissions API недоступен, оставляем prompt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
setPermission('granted')
|
||||||
|
|
||||||
|
const mediaRecorder = new MediaRecorder(stream, {
|
||||||
|
mimeType: 'audio/webm;codecs=opus'
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaRecorderRef.current = mediaRecorder
|
||||||
|
audioChunksRef.current = []
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
audioChunksRef.current.push(event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
|
||||||
|
const audioUrl = URL.createObjectURL(audioBlob)
|
||||||
|
setRecordedAudio(audioUrl)
|
||||||
|
|
||||||
|
// Останавливаем все треки для освобождения микрофона
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
|
||||||
|
// Останавливаем таймер
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
intervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.start()
|
||||||
|
setIsRecording(true)
|
||||||
|
setDuration(0)
|
||||||
|
|
||||||
|
// Запускаем таймер записи
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setDuration(prev => prev + 1)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing microphone:', error)
|
||||||
|
setPermission('denied')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
setIsRecording(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelRecording = () => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
setIsRecording(false)
|
||||||
|
}
|
||||||
|
setRecordedAudio(null)
|
||||||
|
setDuration(0)
|
||||||
|
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
intervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playRecording = () => {
|
||||||
|
if (recordedAudio) {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause()
|
||||||
|
audioRef.current.currentTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRef.current = new Audio(recordedAudio)
|
||||||
|
audioRef.current.play()
|
||||||
|
setIsPlaying(true)
|
||||||
|
|
||||||
|
audioRef.current.onended = () => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendVoiceMessage = async () => {
|
||||||
|
if (!recordedAudio || !user?.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Конвертируем Blob в File для загрузки
|
||||||
|
const response = await fetch(recordedAudio)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' })
|
||||||
|
|
||||||
|
// Загружаем в S3
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('userId', user.id)
|
||||||
|
|
||||||
|
const uploadResponse = await fetch('/api/upload-voice', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error('Failed to upload voice message')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await uploadResponse.json()
|
||||||
|
|
||||||
|
// Отправляем голосовое сообщение
|
||||||
|
onSendVoice(result.url, duration)
|
||||||
|
|
||||||
|
// Очищаем состояние
|
||||||
|
setRecordedAudio(null)
|
||||||
|
setDuration(0)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending voice message:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === 'denied') {
|
||||||
|
return (
|
||||||
|
<div className="text-white/60 text-xs text-center p-2">
|
||||||
|
Доступ к микрофону запрещен
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{!recordedAudio ? (
|
||||||
|
// Состояние записи
|
||||||
|
<>
|
||||||
|
{!isRecording ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={startRecording}
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center space-x-2 bg-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||||
|
<span className="text-white text-sm font-mono">
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={stopRecording}
|
||||||
|
className="text-white/80 hover:text-white p-1"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelRecording}
|
||||||
|
className="text-red-400 hover:text-red-300 p-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Состояние воспроизведения и отправки
|
||||||
|
<div className="flex items-center space-x-2 bg-blue-500/20 rounded-lg px-3 py-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={playRecording}
|
||||||
|
className="text-blue-300 hover:text-blue-200 p-1"
|
||||||
|
>
|
||||||
|
{isPlaying ? <MicOff className="h-3 w-3" /> : <Mic className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
<span className="text-white text-sm font-mono">
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={sendVoiceMessage}
|
||||||
|
className="text-green-400 hover:text-green-300 p-1"
|
||||||
|
>
|
||||||
|
<Send className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRecordedAudio(null)}
|
||||||
|
className="text-red-400 hover:text-red-300 p-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -356,3 +356,194 @@ export const REMOVE_COUNTERPARTY = gql`
|
|||||||
removeCounterparty(organizationId: $organizationId)
|
removeCounterparty(organizationId: $organizationId)
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Мутации для сообщений
|
||||||
|
export const SEND_MESSAGE = gql`
|
||||||
|
mutation SendMessage($receiverOrganizationId: ID!, $content: String!, $type: MessageType = TEXT) {
|
||||||
|
sendMessage(receiverOrganizationId: $receiverOrganizationId, content: $content, type: $type) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
messageData {
|
||||||
|
id
|
||||||
|
content
|
||||||
|
type
|
||||||
|
voiceUrl
|
||||||
|
voiceDuration
|
||||||
|
fileUrl
|
||||||
|
fileName
|
||||||
|
fileSize
|
||||||
|
fileType
|
||||||
|
senderId
|
||||||
|
senderOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receiverOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isRead
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SEND_VOICE_MESSAGE = gql`
|
||||||
|
mutation SendVoiceMessage($receiverOrganizationId: ID!, $voiceUrl: String!, $voiceDuration: Int!) {
|
||||||
|
sendVoiceMessage(receiverOrganizationId: $receiverOrganizationId, voiceUrl: $voiceUrl, voiceDuration: $voiceDuration) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
messageData {
|
||||||
|
id
|
||||||
|
content
|
||||||
|
type
|
||||||
|
voiceUrl
|
||||||
|
voiceDuration
|
||||||
|
fileUrl
|
||||||
|
fileName
|
||||||
|
fileSize
|
||||||
|
fileType
|
||||||
|
senderId
|
||||||
|
senderOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receiverOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isRead
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SEND_IMAGE_MESSAGE = gql`
|
||||||
|
mutation SendImageMessage($receiverOrganizationId: ID!, $fileUrl: String!, $fileName: String!, $fileSize: Int!, $fileType: String!) {
|
||||||
|
sendImageMessage(receiverOrganizationId: $receiverOrganizationId, fileUrl: $fileUrl, fileName: $fileName, fileSize: $fileSize, fileType: $fileType) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
messageData {
|
||||||
|
id
|
||||||
|
content
|
||||||
|
type
|
||||||
|
voiceUrl
|
||||||
|
voiceDuration
|
||||||
|
fileUrl
|
||||||
|
fileName
|
||||||
|
fileSize
|
||||||
|
fileType
|
||||||
|
senderId
|
||||||
|
senderOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receiverOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isRead
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SEND_FILE_MESSAGE = gql`
|
||||||
|
mutation SendFileMessage($receiverOrganizationId: ID!, $fileUrl: String!, $fileName: String!, $fileSize: Int!, $fileType: String!) {
|
||||||
|
sendFileMessage(receiverOrganizationId: $receiverOrganizationId, fileUrl: $fileUrl, fileName: $fileName, fileSize: $fileSize, fileType: $fileType) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
messageData {
|
||||||
|
id
|
||||||
|
content
|
||||||
|
type
|
||||||
|
voiceUrl
|
||||||
|
voiceDuration
|
||||||
|
fileUrl
|
||||||
|
fileName
|
||||||
|
fileSize
|
||||||
|
fileType
|
||||||
|
senderId
|
||||||
|
senderOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receiverOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isRead
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const MARK_MESSAGES_AS_READ = gql`
|
||||||
|
mutation MarkMessagesAsRead($conversationId: ID!) {
|
||||||
|
markMessagesAsRead(conversationId: $conversationId)
|
||||||
|
}
|
||||||
|
`
|
@ -6,6 +6,7 @@ export const GET_ME = gql`
|
|||||||
id
|
id
|
||||||
phone
|
phone
|
||||||
avatar
|
avatar
|
||||||
|
managerName
|
||||||
createdAt
|
createdAt
|
||||||
organization {
|
organization {
|
||||||
id
|
id
|
||||||
@ -61,9 +62,13 @@ export const SEARCH_ORGANIZATIONS = gql`
|
|||||||
emails
|
emails
|
||||||
createdAt
|
createdAt
|
||||||
isCounterparty
|
isCounterparty
|
||||||
|
isCurrentUser
|
||||||
|
hasOutgoingRequest
|
||||||
|
hasIncomingRequest
|
||||||
users {
|
users {
|
||||||
id
|
id
|
||||||
avatar
|
avatar
|
||||||
|
managerName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,6 +81,7 @@ export const GET_MY_COUNTERPARTIES = gql`
|
|||||||
inn
|
inn
|
||||||
name
|
name
|
||||||
fullName
|
fullName
|
||||||
|
managementName
|
||||||
type
|
type
|
||||||
address
|
address
|
||||||
phones
|
phones
|
||||||
@ -84,6 +90,7 @@ export const GET_MY_COUNTERPARTIES = gql`
|
|||||||
users {
|
users {
|
||||||
id
|
id
|
||||||
avatar
|
avatar
|
||||||
|
managerName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -105,6 +112,11 @@ export const GET_INCOMING_REQUESTS = gql`
|
|||||||
address
|
address
|
||||||
phones
|
phones
|
||||||
emails
|
emails
|
||||||
|
createdAt
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
receiver {
|
receiver {
|
||||||
id
|
id
|
||||||
@ -112,6 +124,10 @@ export const GET_INCOMING_REQUESTS = gql`
|
|||||||
name
|
name
|
||||||
fullName
|
fullName
|
||||||
type
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -130,6 +146,10 @@ export const GET_OUTGOING_REQUESTS = gql`
|
|||||||
name
|
name
|
||||||
fullName
|
fullName
|
||||||
type
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
receiver {
|
receiver {
|
||||||
id
|
id
|
||||||
@ -140,6 +160,11 @@ export const GET_OUTGOING_REQUESTS = gql`
|
|||||||
address
|
address
|
||||||
phones
|
phones
|
||||||
emails
|
emails
|
||||||
|
createdAt
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -167,3 +192,83 @@ export const GET_ORGANIZATION = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Запросы для сообщений
|
||||||
|
export const GET_MESSAGES = gql`
|
||||||
|
query GetMessages($counterpartyId: ID!, $limit: Int, $offset: Int) {
|
||||||
|
messages(counterpartyId: $counterpartyId, limit: $limit, offset: $offset) {
|
||||||
|
id
|
||||||
|
content
|
||||||
|
type
|
||||||
|
voiceUrl
|
||||||
|
voiceDuration
|
||||||
|
fileUrl
|
||||||
|
fileName
|
||||||
|
fileSize
|
||||||
|
fileType
|
||||||
|
senderId
|
||||||
|
senderOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receiverOrganization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isRead
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_CONVERSATIONS = gql`
|
||||||
|
query GetConversations {
|
||||||
|
conversations {
|
||||||
|
id
|
||||||
|
counterparty {
|
||||||
|
id
|
||||||
|
inn
|
||||||
|
name
|
||||||
|
fullName
|
||||||
|
type
|
||||||
|
address
|
||||||
|
users {
|
||||||
|
id
|
||||||
|
avatar
|
||||||
|
managerName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastMessage {
|
||||||
|
id
|
||||||
|
content
|
||||||
|
type
|
||||||
|
voiceUrl
|
||||||
|
voiceDuration
|
||||||
|
fileUrl
|
||||||
|
fileName
|
||||||
|
fileSize
|
||||||
|
fileType
|
||||||
|
senderId
|
||||||
|
isRead
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
unreadCount
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
@ -179,8 +179,30 @@ export const resolvers = {
|
|||||||
|
|
||||||
const existingCounterpartyIds = existingCounterparties.map(c => c.counterpartyId)
|
const existingCounterpartyIds = existingCounterparties.map(c => c.counterpartyId)
|
||||||
|
|
||||||
const where: any = {
|
// Получаем исходящие заявки для добавления флага hasOutgoingRequest
|
||||||
id: { not: currentUser.organization.id } // Исключаем только собственную организацию
|
const outgoingRequests = await prisma.counterpartyRequest.findMany({
|
||||||
|
where: {
|
||||||
|
senderId: currentUser.organization.id,
|
||||||
|
status: 'PENDING'
|
||||||
|
},
|
||||||
|
select: { receiverId: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const outgoingRequestIds = outgoingRequests.map(r => r.receiverId)
|
||||||
|
|
||||||
|
// Получаем входящие заявки для добавления флага hasIncomingRequest
|
||||||
|
const incomingRequests = await prisma.counterpartyRequest.findMany({
|
||||||
|
where: {
|
||||||
|
receiverId: currentUser.organization.id,
|
||||||
|
status: 'PENDING'
|
||||||
|
},
|
||||||
|
select: { senderId: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const incomingRequestIds = incomingRequests.map(r => r.senderId)
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {
|
||||||
|
// Больше не исключаем собственную организацию
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.type) {
|
if (args.type) {
|
||||||
@ -205,10 +227,13 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Добавляем флаг isCounterparty к каждой организации
|
// Добавляем флаги isCounterparty, isCurrentUser, hasOutgoingRequest и hasIncomingRequest к каждой организации
|
||||||
return organizations.map(org => ({
|
return organizations.map(org => ({
|
||||||
...org,
|
...org,
|
||||||
isCounterparty: existingCounterpartyIds.includes(org.id)
|
isCounterparty: existingCounterpartyIds.includes(org.id),
|
||||||
|
isCurrentUser: org.id === currentUser.organization?.id,
|
||||||
|
hasOutgoingRequest: outgoingRequestIds.includes(org.id),
|
||||||
|
hasIncomingRequest: incomingRequestIds.includes(org.id)
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -322,6 +347,82 @@ export const resolvers = {
|
|||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Сообщения с контрагентом
|
||||||
|
messages: async (_: unknown, args: { counterpartyId: string; limit?: number; offset?: number }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = args.limit || 50
|
||||||
|
const offset = args.offset || 0
|
||||||
|
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
senderOrganizationId: currentUser.organization.id,
|
||||||
|
receiverOrganizationId: args.counterpartyId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
senderOrganizationId: args.counterpartyId,
|
||||||
|
receiverOrganizationId: currentUser.organization.id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
senderOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiverOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
skip: offset
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages
|
||||||
|
},
|
||||||
|
|
||||||
|
// Список чатов (последние сообщения с каждым контрагентом)
|
||||||
|
conversations: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Здесь будет логика получения списка чатов
|
||||||
|
// Пока возвращаем пустой массив, так как таблица сообщений еще не создана
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -608,13 +709,16 @@ export const resolvers = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем организацию селлера - используем название магазина как основное имя
|
// Создаем организацию селлера - используем tradeMark как основное имя
|
||||||
const shopName = validationResults[0]?.data?.sellerName || 'Магазин'
|
const tradeMark = validationResults[0]?.data?.tradeMark
|
||||||
|
const sellerName = validationResults[0]?.data?.sellerName
|
||||||
|
const shopName = tradeMark || sellerName || 'Магазин'
|
||||||
|
|
||||||
const organization = await prisma.organization.create({
|
const organization = await prisma.organization.create({
|
||||||
data: {
|
data: {
|
||||||
inn: validationResults[0]?.data?.inn || `SELLER_${Date.now()}`,
|
inn: validationResults[0]?.data?.inn || `SELLER_${Date.now()}`,
|
||||||
name: shopName,
|
name: shopName, // Используем tradeMark как основное название
|
||||||
fullName: `Интернет-магазин "${shopName}"`,
|
fullName: sellerName ? `${sellerName} (${shopName})` : `Интернет-магазин "${shopName}"`,
|
||||||
type: 'SELLER'
|
type: 'SELLER'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -858,11 +962,19 @@ export const resolvers = {
|
|||||||
try {
|
try {
|
||||||
const { input } = args
|
const { input } = args
|
||||||
|
|
||||||
// Обновляем аватар пользователя если указан
|
// Обновляем данные пользователя (аватар, имя управляющего)
|
||||||
|
const userUpdateData: { avatar?: string; managerName?: string } = {}
|
||||||
if (input.avatar) {
|
if (input.avatar) {
|
||||||
|
userUpdateData.avatar = input.avatar
|
||||||
|
}
|
||||||
|
if (input.managerName) {
|
||||||
|
userUpdateData.managerName = input.managerName
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(userUpdateData).length > 0) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: context.user.id },
|
where: { id: context.user.id },
|
||||||
data: { avatar: input.avatar }
|
data: userUpdateData
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -874,6 +986,9 @@ export const resolvers = {
|
|||||||
managementPost?: string
|
managementPost?: string
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
|
// Название организации больше не обновляется через профиль
|
||||||
|
// Для селлеров устанавливается при регистрации, для остальных - при смене ИНН
|
||||||
|
|
||||||
// Обновляем контактные данные в JSON поле phones
|
// Обновляем контактные данные в JSON поле phones
|
||||||
if (input.orgPhone) {
|
if (input.orgPhone) {
|
||||||
updateData.phones = [{ value: input.orgPhone, type: 'main' }]
|
updateData.phones = [{ value: input.orgPhone, type: 'main' }]
|
||||||
@ -898,9 +1013,7 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
} = {}
|
} = {}
|
||||||
|
|
||||||
if (input.managerName) {
|
// managerName теперь сохраняется в поле пользователя, а не в JSON
|
||||||
customContacts.managerName = input.managerName
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.telegram) {
|
if (input.telegram) {
|
||||||
customContacts.telegram = input.telegram
|
customContacts.telegram = input.telegram
|
||||||
@ -1015,7 +1128,8 @@ export const resolvers = {
|
|||||||
// Подготавливаем данные для обновления
|
// Подготавливаем данные для обновления
|
||||||
const updateData: Prisma.OrganizationUpdateInput = {
|
const updateData: Prisma.OrganizationUpdateInput = {
|
||||||
kpp: organizationData.kpp,
|
kpp: organizationData.kpp,
|
||||||
name: organizationData.name,
|
// Для селлеров не обновляем название организации (это название магазина)
|
||||||
|
...(user.organization.type !== 'SELLER' && { name: organizationData.name }),
|
||||||
fullName: organizationData.fullName,
|
fullName: organizationData.fullName,
|
||||||
address: organizationData.address,
|
address: organizationData.address,
|
||||||
addressFull: organizationData.addressFull,
|
addressFull: organizationData.addressFull,
|
||||||
@ -1023,7 +1137,7 @@ export const resolvers = {
|
|||||||
ogrnDate: organizationData.ogrnDate ? organizationData.ogrnDate.toISOString() : null,
|
ogrnDate: organizationData.ogrnDate ? organizationData.ogrnDate.toISOString() : null,
|
||||||
registrationDate: organizationData.registrationDate ? organizationData.registrationDate.toISOString() : null,
|
registrationDate: organizationData.registrationDate ? organizationData.registrationDate.toISOString() : null,
|
||||||
liquidationDate: organizationData.liquidationDate ? organizationData.liquidationDate.toISOString() : null,
|
liquidationDate: organizationData.liquidationDate ? organizationData.liquidationDate.toISOString() : null,
|
||||||
managementName: organizationData.managementName,
|
managementName: organizationData.managementName, // Всегда перезаписываем данными из DaData (может быть null)
|
||||||
managementPost: user.organization.managementPost, // Сохраняем кастомные данные пользователя
|
managementPost: user.organization.managementPost, // Сохраняем кастомные данные пользователя
|
||||||
opfCode: organizationData.opfCode,
|
opfCode: organizationData.opfCode,
|
||||||
opfFull: organizationData.opfFull,
|
opfFull: organizationData.opfFull,
|
||||||
@ -1321,6 +1435,317 @@ export const resolvers = {
|
|||||||
console.error('Error removing counterparty:', error)
|
console.error('Error removing counterparty:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Отправить сообщение
|
||||||
|
sendMessage: async (_: unknown, args: { receiverOrganizationId: string; content?: string; type?: 'TEXT' | 'VOICE' }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что получатель является контрагентом
|
||||||
|
const isCounterparty = await prisma.counterparty.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
counterpartyId: args.receiverOrganizationId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isCounterparty) {
|
||||||
|
throw new GraphQLError('Можно отправлять сообщения только контрагентам')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем организацию получателя
|
||||||
|
const receiverOrganization = await prisma.organization.findUnique({
|
||||||
|
where: { id: args.receiverOrganizationId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!receiverOrganization) {
|
||||||
|
throw new GraphQLError('Организация получателя не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем сообщение
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
content: args.content?.trim() || null,
|
||||||
|
type: args.type || 'TEXT',
|
||||||
|
senderId: context.user.id,
|
||||||
|
senderOrganizationId: currentUser.organization.id,
|
||||||
|
receiverOrganizationId: args.receiverOrganizationId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
senderOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiverOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Сообщение отправлено',
|
||||||
|
messageData: message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при отправке сообщения'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Отправить голосовое сообщение
|
||||||
|
sendVoiceMessage: async (_: unknown, args: { receiverOrganizationId: string; voiceUrl: string; voiceDuration: number }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что получатель является контрагентом
|
||||||
|
const isCounterparty = await prisma.counterparty.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
counterpartyId: args.receiverOrganizationId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isCounterparty) {
|
||||||
|
throw new GraphQLError('Можно отправлять сообщения только контрагентам')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем организацию получателя
|
||||||
|
const receiverOrganization = await prisma.organization.findUnique({
|
||||||
|
where: { id: args.receiverOrganizationId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!receiverOrganization) {
|
||||||
|
throw new GraphQLError('Организация получателя не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем голосовое сообщение
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
content: null,
|
||||||
|
type: 'VOICE',
|
||||||
|
voiceUrl: args.voiceUrl,
|
||||||
|
voiceDuration: args.voiceDuration,
|
||||||
|
senderId: context.user.id,
|
||||||
|
senderOrganizationId: currentUser.organization.id,
|
||||||
|
receiverOrganizationId: args.receiverOrganizationId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
senderOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiverOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Голосовое сообщение отправлено',
|
||||||
|
messageData: message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending voice message:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при отправке голосового сообщения'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Отправить изображение
|
||||||
|
sendImageMessage: async (_: unknown, args: { receiverOrganizationId: string; fileUrl: string; fileName: string; fileSize: number; fileType: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что получатель является контрагентом
|
||||||
|
const isCounterparty = await prisma.counterparty.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
counterpartyId: args.receiverOrganizationId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isCounterparty) {
|
||||||
|
throw new GraphQLError('Можно отправлять сообщения только контрагентам')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
content: null,
|
||||||
|
type: 'IMAGE',
|
||||||
|
fileUrl: args.fileUrl,
|
||||||
|
fileName: args.fileName,
|
||||||
|
fileSize: args.fileSize,
|
||||||
|
fileType: args.fileType,
|
||||||
|
senderId: context.user.id,
|
||||||
|
senderOrganizationId: currentUser.organization.id,
|
||||||
|
receiverOrganizationId: args.receiverOrganizationId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
senderOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiverOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Изображение отправлено',
|
||||||
|
messageData: message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending image:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при отправке изображения'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Отправить файл
|
||||||
|
sendFileMessage: async (_: unknown, args: { receiverOrganizationId: string; fileUrl: string; fileName: string; fileSize: number; fileType: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что получатель является контрагентом
|
||||||
|
const isCounterparty = await prisma.counterparty.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
counterpartyId: args.receiverOrganizationId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isCounterparty) {
|
||||||
|
throw new GraphQLError('Можно отправлять сообщения только контрагентам')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
content: null,
|
||||||
|
type: 'FILE',
|
||||||
|
fileUrl: args.fileUrl,
|
||||||
|
fileName: args.fileName,
|
||||||
|
fileSize: args.fileSize,
|
||||||
|
fileType: args.fileType,
|
||||||
|
senderId: context.user.id,
|
||||||
|
senderOrganizationId: currentUser.organization.id,
|
||||||
|
receiverOrganizationId: args.receiverOrganizationId
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
sender: true,
|
||||||
|
senderOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiverOrganization: {
|
||||||
|
include: {
|
||||||
|
users: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Файл отправлен',
|
||||||
|
messageData: message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending file:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при отправке файла'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Отметить сообщения как прочитанные
|
||||||
|
markMessagesAsRead: async (_: unknown, args: { conversationId: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Здесь будет логика обновления статуса сообщений
|
||||||
|
// Пока возвращаем успешный ответ
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1359,5 +1784,23 @@ export const resolvers = {
|
|||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Message: {
|
||||||
|
type: (parent: { type?: string | null }) => {
|
||||||
|
return parent.type || 'TEXT'
|
||||||
|
},
|
||||||
|
createdAt: (parent: { createdAt: Date | string }) => {
|
||||||
|
if (parent.createdAt instanceof Date) {
|
||||||
|
return parent.createdAt.toISOString()
|
||||||
|
}
|
||||||
|
return parent.createdAt
|
||||||
|
},
|
||||||
|
updatedAt: (parent: { updatedAt: Date | string }) => {
|
||||||
|
if (parent.updatedAt instanceof Date) {
|
||||||
|
return parent.updatedAt.toISOString()
|
||||||
|
}
|
||||||
|
return parent.updatedAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -16,6 +16,12 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Исходящие заявки
|
# Исходящие заявки
|
||||||
outgoingRequests: [CounterpartyRequest!]!
|
outgoingRequests: [CounterpartyRequest!]!
|
||||||
|
|
||||||
|
# Сообщения с контрагентом
|
||||||
|
messages(counterpartyId: ID!, limit: Int, offset: Int): [Message!]!
|
||||||
|
|
||||||
|
# Список чатов (последние сообщения с каждым контрагентом)
|
||||||
|
conversations: [Conversation!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@ -48,6 +54,13 @@ export const typeDefs = gql`
|
|||||||
respondToCounterpartyRequest(requestId: ID!, accept: Boolean!): CounterpartyRequestResponse!
|
respondToCounterpartyRequest(requestId: ID!, accept: Boolean!): CounterpartyRequestResponse!
|
||||||
cancelCounterpartyRequest(requestId: ID!): Boolean!
|
cancelCounterpartyRequest(requestId: ID!): Boolean!
|
||||||
removeCounterparty(organizationId: ID!): Boolean!
|
removeCounterparty(organizationId: ID!): Boolean!
|
||||||
|
|
||||||
|
# Работа с сообщениями
|
||||||
|
sendMessage(receiverOrganizationId: ID!, content: String, type: MessageType = TEXT): MessageResponse!
|
||||||
|
sendVoiceMessage(receiverOrganizationId: ID!, voiceUrl: String!, voiceDuration: Int!): MessageResponse!
|
||||||
|
sendImageMessage(receiverOrganizationId: ID!, fileUrl: String!, fileName: String!, fileSize: Int!, fileType: String!): MessageResponse!
|
||||||
|
sendFileMessage(receiverOrganizationId: ID!, fileUrl: String!, fileName: String!, fileSize: Int!, fileType: String!): MessageResponse!
|
||||||
|
markMessagesAsRead(conversationId: ID!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
# Типы данных
|
# Типы данных
|
||||||
@ -55,6 +68,7 @@ export const typeDefs = gql`
|
|||||||
id: ID!
|
id: ID!
|
||||||
phone: String!
|
phone: String!
|
||||||
avatar: String
|
avatar: String
|
||||||
|
managerName: String
|
||||||
organization: Organization
|
organization: Organization
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
updatedAt: String!
|
updatedAt: String!
|
||||||
@ -92,6 +106,9 @@ export const typeDefs = gql`
|
|||||||
users: [User!]!
|
users: [User!]!
|
||||||
apiKeys: [ApiKey!]!
|
apiKeys: [ApiKey!]!
|
||||||
isCounterparty: Boolean
|
isCounterparty: Boolean
|
||||||
|
isCurrentUser: Boolean
|
||||||
|
hasOutgoingRequest: Boolean
|
||||||
|
hasIncomingRequest: Boolean
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
updatedAt: String!
|
updatedAt: String!
|
||||||
}
|
}
|
||||||
@ -224,6 +241,46 @@ export const typeDefs = gql`
|
|||||||
request: CounterpartyRequest
|
request: CounterpartyRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Типы для сообщений
|
||||||
|
type Message {
|
||||||
|
id: ID!
|
||||||
|
content: String
|
||||||
|
type: MessageType
|
||||||
|
voiceUrl: String
|
||||||
|
voiceDuration: Int
|
||||||
|
fileUrl: String
|
||||||
|
fileName: String
|
||||||
|
fileSize: Int
|
||||||
|
fileType: String
|
||||||
|
senderId: ID!
|
||||||
|
senderOrganization: Organization!
|
||||||
|
receiverOrganization: Organization!
|
||||||
|
isRead: Boolean!
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageType {
|
||||||
|
TEXT
|
||||||
|
VOICE
|
||||||
|
IMAGE
|
||||||
|
FILE
|
||||||
|
}
|
||||||
|
|
||||||
|
type Conversation {
|
||||||
|
id: ID!
|
||||||
|
counterparty: Organization!
|
||||||
|
lastMessage: Message
|
||||||
|
unreadCount: Int!
|
||||||
|
updatedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
messageData: Message
|
||||||
|
}
|
||||||
|
|
||||||
# JSON скаляр
|
# JSON скаляр
|
||||||
scalar JSON
|
scalar JSON
|
||||||
`
|
`
|
@ -14,6 +14,7 @@ interface User {
|
|||||||
id: string
|
id: string
|
||||||
phone: string
|
phone: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
|
managerName?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
organization?: {
|
organization?: {
|
||||||
id: string
|
id: string
|
||||||
|
@ -6,6 +6,7 @@ export interface MarketplaceValidationResult {
|
|||||||
data?: {
|
data?: {
|
||||||
sellerId?: string
|
sellerId?: string
|
||||||
sellerName?: string
|
sellerName?: string
|
||||||
|
tradeMark?: string
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -56,9 +57,9 @@ export class MarketplaceService {
|
|||||||
isValid: true,
|
isValid: true,
|
||||||
message: 'API ключ Wildberries валиден',
|
message: 'API ключ Wildberries валиден',
|
||||||
data: {
|
data: {
|
||||||
sellerId: sellerData.id?.toString(),
|
sellerId: sellerData.sid, // sid - это уникальный ID продавца
|
||||||
sellerName: sellerData.name || sellerData.supplierName,
|
sellerName: sellerData.name, // обычное наименование продавца
|
||||||
inn: sellerData.inn
|
tradeMark: sellerData.tradeMark // торговое наименование продавца
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -86,6 +87,13 @@ export class MarketplaceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
message: 'Слишком много запросов к Wildberries API. Попробуйте позже'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (error.code === 'ECONNABORTED') {
|
if (error.code === 'ECONNABORTED') {
|
||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
|
Reference in New Issue
Block a user