Добавлены новые зависимости для работы с эмодзи и улучшена структура базы данных. Реализована модель сообщений и обновлены компоненты для поддержки новых функций мессенджера. Обновлены запросы и мутации для работы с сообщениями и чатом.
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",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"emoji-picker-react": "^4.13.2",
|
||||
"express": "^5.1.0",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
@ -6302,6 +6303,21 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"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": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@ -7140,6 +7156,12 @@
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
|
@ -33,6 +33,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"emoji-picker-react": "^4.13.2",
|
||||
"express": "^5.1.0",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
|
@ -15,6 +15,7 @@ model User {
|
||||
id String @id @default(cuid())
|
||||
phone String @unique
|
||||
avatar String? // URL аватара в S3
|
||||
managerName String? // Имя управляющего
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@ -25,6 +26,9 @@ model User {
|
||||
// SMS коды для авторизации
|
||||
smsCodes SmsCode[]
|
||||
|
||||
// Отправленные сообщения
|
||||
sentMessages Message[] @relation("SentMessages")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@ -106,6 +110,10 @@ model Organization {
|
||||
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
|
||||
counterpartyOf Counterparty[] @relation("CounterpartyOf")
|
||||
|
||||
// Сообщения
|
||||
sentMessages Message[] @relation("SentMessages")
|
||||
receivedMessages Message[] @relation("ReceivedMessages")
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
@ -189,3 +197,41 @@ enum CounterpartyRequestStatus {
|
||||
REJECTED // Отклонена
|
||||
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 {
|
||||
sellerId?: string
|
||||
sellerName?: string
|
||||
tradeMark?: string
|
||||
isValid?: boolean
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,7 @@ interface OrganizationData {
|
||||
interface ApiKeyValidation {
|
||||
sellerId?: string
|
||||
sellerName?: string
|
||||
tradeMark?: string
|
||||
isValid?: boolean
|
||||
}
|
||||
|
||||
@ -249,9 +250,9 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr
|
||||
WB
|
||||
</Badge>
|
||||
</div>
|
||||
{data.wbApiValidation?.sellerName ? (
|
||||
{data.wbApiValidation?.tradeMark || data.wbApiValidation?.sellerName ? (
|
||||
<span className="text-white/70 text-xs max-w-[120px] text-right truncate">
|
||||
{data.wbApiValidation.sellerName}
|
||||
{data.wbApiValidation.tradeMark || data.wbApiValidation.sellerName}
|
||||
</span>
|
||||
) : (
|
||||
<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.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/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">
|
||||
{data.wbApiValidation.sellerName}
|
||||
</span>
|
||||
|
@ -15,6 +15,7 @@ import { getAuthToken } from '@/lib/apollo-client'
|
||||
interface ApiValidationData {
|
||||
sellerId?: string
|
||||
sellerName?: string
|
||||
tradeMark?: string
|
||||
isValid?: boolean
|
||||
}
|
||||
|
||||
@ -97,12 +98,14 @@ export function MarketplaceApiStep({ onNext, onBack }: MarketplaceApiStepProps)
|
||||
setWbValidationData({
|
||||
sellerId: validationData.sellerId,
|
||||
sellerName: validationData.sellerName,
|
||||
tradeMark: validationData.tradeMark,
|
||||
isValid: true
|
||||
})
|
||||
} else if (marketplace === 'ozon') {
|
||||
setOzonValidationData({
|
||||
sellerId: validationData.sellerId,
|
||||
sellerName: validationData.sellerName,
|
||||
tradeMark: validationData.tradeMark,
|
||||
isValid: true
|
||||
})
|
||||
}
|
||||
|
@ -10,7 +10,8 @@ import {
|
||||
Settings,
|
||||
LogOut,
|
||||
Building2,
|
||||
Store
|
||||
Store,
|
||||
MessageCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
export function Sidebar() {
|
||||
@ -58,8 +59,13 @@ export function Sidebar() {
|
||||
router.push('/market')
|
||||
}
|
||||
|
||||
const handleMessengerClick = () => {
|
||||
router.push('/messenger')
|
||||
}
|
||||
|
||||
const isSettingsActive = pathname === '/settings'
|
||||
const isMarketActive = pathname.startsWith('/market')
|
||||
const isMessengerActive = pathname.startsWith('/messenger')
|
||||
|
||||
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">
|
||||
@ -67,30 +73,33 @@ export function Sidebar() {
|
||||
|
||||
|
||||
{/* Информация о пользователе */}
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="h-10 w-10">
|
||||
{user?.avatar ? (
|
||||
<AvatarImage
|
||||
src={user.avatar}
|
||||
alt="Аватар пользователя"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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-3">
|
||||
<div className="relative">
|
||||
<Avatar className="h-12 w-12 flex-shrink-0 ring-2 ring-white/20">
|
||||
{user?.avatar ? (
|
||||
<AvatarImage
|
||||
src={user.avatar}
|
||||
alt="Аватар пользователя"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-sm font-semibold">
|
||||
{getInitials()}
|
||||
</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 items-center space-x-1 mb-1">
|
||||
<Building2 className="h-3 w-3 text-white/60" />
|
||||
<p className="text-white text-xs font-medium truncate">
|
||||
{getOrganizationName()}
|
||||
<p className="text-white text-sm font-semibold truncate mb-1" title={getOrganizationName()}>
|
||||
{getOrganizationName()}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-white/60 text-xs truncate">
|
||||
{getCabinetType()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@ -110,6 +119,19 @@ export function Sidebar() {
|
||||
Маркет
|
||||
</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
|
||||
variant={isSettingsActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
|
@ -70,7 +70,11 @@ export function UserSettings() {
|
||||
bankName: '',
|
||||
bik: '',
|
||||
accountNumber: '',
|
||||
corrAccount: ''
|
||||
corrAccount: '',
|
||||
|
||||
// API ключи маркетплейсов
|
||||
wildberriesApiKey: '',
|
||||
ozonApiKey: ''
|
||||
})
|
||||
|
||||
// Загружаем данные организации при монтировании компонента
|
||||
@ -122,7 +126,7 @@ export function UserSettings() {
|
||||
|
||||
setFormData({
|
||||
orgPhone: orgPhone,
|
||||
managerName: customContacts?.managerName || '',
|
||||
managerName: user?.managerName || '',
|
||||
telegram: customContacts?.telegram || '',
|
||||
whatsapp: customContacts?.whatsapp || '',
|
||||
email: email,
|
||||
@ -135,7 +139,9 @@ export function UserSettings() {
|
||||
bankName: customContacts?.bankDetails?.bankName || '',
|
||||
bik: customContacts?.bankDetails?.bik || '',
|
||||
accountNumber: customContacts?.bankDetails?.accountNumber || '',
|
||||
corrAccount: customContacts?.bankDetails?.corrAccount || ''
|
||||
corrAccount: customContacts?.bankDetails?.corrAccount || '',
|
||||
wildberriesApiKey: '',
|
||||
ozonApiKey: ''
|
||||
})
|
||||
}
|
||||
}, [user])
|
||||
@ -176,8 +182,8 @@ export function UserSettings() {
|
||||
|
||||
// Дополнительные поля в зависимости от типа кабинета
|
||||
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(
|
||||
{ field: 'bankName', label: 'Название банка', value: formData.bankName },
|
||||
{ field: 'bik', label: 'БИК', value: formData.bik },
|
||||
@ -386,13 +392,30 @@ export function UserSettings() {
|
||||
const cleaned = value.replace(/\D/g, '')
|
||||
return cleaned.length !== 11 ? 'Неверный формат телефона' : null
|
||||
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':
|
||||
// Игнорируем автоматически сгенерированные ИНН селлеров
|
||||
if (value.startsWith('SELLER_')) {
|
||||
return null
|
||||
}
|
||||
const innCleaned = value.replace(/\D/g, '')
|
||||
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
|
||||
return 'ИНН должен содержать 10 или 12 цифр'
|
||||
}
|
||||
return null
|
||||
case 'bankName':
|
||||
return value.trim().length < 3 ? 'Минимум 3 символа' : null
|
||||
case 'bik':
|
||||
const bikCleaned = value.replace(/\D/g, '')
|
||||
return bikCleaned.length !== 9 ? 'БИК должен содержать 9 цифр' : null
|
||||
case 'accountNumber':
|
||||
const accountCleaned = value.replace(/\D/g, '')
|
||||
return accountCleaned.length !== 20 ? 'Расчетный счет должен содержать 20 цифр' : null
|
||||
case 'corrAccount':
|
||||
const corrCleaned = value.replace(/\D/g, '')
|
||||
return corrCleaned.length !== 20 ? 'Корр. счет должен содержать 20 цифр' : null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@ -400,11 +423,20 @@ export function UserSettings() {
|
||||
|
||||
// Проверка наличия ошибок валидации
|
||||
const hasValidationErrors = () => {
|
||||
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn']
|
||||
return fields.some(field => {
|
||||
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn', 'bankName', 'bik', 'accountNumber', 'corrAccount']
|
||||
|
||||
// Проверяем ошибки валидации только в заполненных полях
|
||||
const hasErrors = fields.some(field => {
|
||||
const value = formData[field as keyof typeof formData]
|
||||
return getFieldError(field, value)
|
||||
// Проверяем ошибки только для заполненных полей
|
||||
if (!value || !value.trim()) return false
|
||||
|
||||
const error = getFieldError(field, value)
|
||||
return error !== null
|
||||
})
|
||||
|
||||
// Убираем проверку обязательных полей - пользователь может заполнять постепенно
|
||||
return hasErrors
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
@ -437,19 +469,33 @@ export function UserSettings() {
|
||||
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({
|
||||
variables: {
|
||||
input: {
|
||||
orgPhone: formData.orgPhone,
|
||||
managerName: formData.managerName,
|
||||
telegram: formData.telegram,
|
||||
whatsapp: formData.whatsapp,
|
||||
email: formData.email,
|
||||
bankName: formData.bankName,
|
||||
bik: formData.bik,
|
||||
accountNumber: formData.accountNumber,
|
||||
corrAccount: formData.corrAccount
|
||||
}
|
||||
input: inputData
|
||||
}
|
||||
})
|
||||
|
||||
@ -516,19 +562,18 @@ export function UserSettings() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Компактный индикатор прогресса */}
|
||||
{isIncomplete && (
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
|
||||
</div>
|
||||
<div className="hidden sm:block text-xs text-white/70">
|
||||
Осталось {profileStatus.missingFields.length} {
|
||||
profileStatus.missingFields.length === 1 ? 'поле' :
|
||||
profileStatus.missingFields.length < 5 ? 'поля' : 'полей'
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden sm:block text-xs text-white/70">
|
||||
{isIncomplete ? (
|
||||
<>Заполнено {profileStatus.percentage}% профиля</>
|
||||
) : (
|
||||
<>Профиль полностью заполнен</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
@ -590,10 +635,10 @@ export function UserSettings() {
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
Организация
|
||||
</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">
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
Финансовые
|
||||
Финансы
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{user?.organization?.type === 'SELLER' && (
|
||||
@ -602,10 +647,12 @@ export function UserSettings() {
|
||||
API
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Инструменты
|
||||
</TabsTrigger>
|
||||
{user?.organization?.type !== 'SELLER' && (
|
||||
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Инструменты
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Профиль пользователя */}
|
||||
@ -671,7 +718,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
|
||||
<Input
|
||||
value={formData.orgPhone}
|
||||
value={formData.orgPhone || ''}
|
||||
onChange={(e) => handleInputChange('orgPhone', e.target.value)}
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
readOnly={!isEditing}
|
||||
@ -679,23 +726,18 @@ export function UserSettings() {
|
||||
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">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{getFieldError('orgPhone', formData.orgPhone)}
|
||||
</p>
|
||||
) : !formData.orgPhone && (
|
||||
<p className="text-orange-400 text-xs mt-1 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Рекомендуется указать
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Имя управляющего</Label>
|
||||
<Input
|
||||
value={formData.managerName}
|
||||
value={formData.managerName || ''}
|
||||
onChange={(e) => handleInputChange('managerName', e.target.value)}
|
||||
placeholder="Иван Иванов"
|
||||
readOnly={!isEditing}
|
||||
@ -719,7 +761,7 @@ export function UserSettings() {
|
||||
Telegram
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.telegram}
|
||||
value={formData.telegram || ''}
|
||||
onChange={(e) => handleInputChange('telegram', e.target.value)}
|
||||
placeholder="@username"
|
||||
readOnly={!isEditing}
|
||||
@ -741,7 +783,7 @@ export function UserSettings() {
|
||||
WhatsApp
|
||||
</Label>
|
||||
<Input
|
||||
value={formData.whatsapp}
|
||||
value={formData.whatsapp || ''}
|
||||
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
|
||||
placeholder="+7 (999) 999-99-99"
|
||||
readOnly={!isEditing}
|
||||
@ -764,7 +806,7 @@ export function UserSettings() {
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="example@company.com"
|
||||
readOnly={!isEditing}
|
||||
@ -807,14 +849,25 @@ export function UserSettings() {
|
||||
{/* Названия */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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
|
||||
value={formData.orgName || user?.organization?.name || ''}
|
||||
onChange={(e) => handleInputChange('orgName', e.target.value)}
|
||||
placeholder="Название организации"
|
||||
readOnly={!isEditing || !!(formData.orgName || user?.organization?.name)}
|
||||
placeholder={user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
|
||||
readOnly={true}
|
||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||
/>
|
||||
{user?.organization?.type === 'SELLER' ? (
|
||||
<p className="text-white/50 text-xs mt-1">
|
||||
Название устанавливается при регистрации кабинета и не может быть изменено.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-white/50 text-xs mt-1">
|
||||
Автоматически заполняется из федерального реестра при указании ИНН.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -904,27 +957,29 @@ export function UserSettings() {
|
||||
|
||||
{/* Руководитель и статус */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{user?.organization?.managementName && (
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Руководитель</Label>
|
||||
<Input
|
||||
value={user.organization.managementName}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Руководитель организации</Label>
|
||||
<Input
|
||||
value={user?.organization?.managementName || 'Данные не указаны в реестре'}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
placeholder="Данные отсутствуют в федеральном реестре"
|
||||
/>
|
||||
<p className="text-white/50 text-xs mt-1">
|
||||
{user?.organization?.managementName
|
||||
? 'Данные из федерального реестра'
|
||||
: 'Автоматически заполняется из федерального реестра при указании ИНН'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{user?.organization?.status && (
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
|
||||
<Input
|
||||
value={user.organization.status === 'ACTIVE' ? 'Действующая' : user.organization.status}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
|
||||
<Input
|
||||
value={user?.organization?.status === 'ACTIVE' ? 'Действующая' : user?.organization?.status || 'Статус не указан'}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Дата регистрации */}
|
||||
@ -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">
|
||||
<Card className="glass-card p-6 h-full overflow-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@ -965,7 +1020,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Название банка</Label>
|
||||
<Input
|
||||
value={formData.bankName}
|
||||
value={formData.bankName || ''}
|
||||
onChange={(e) => handleInputChange('bankName', e.target.value)}
|
||||
placeholder="ПАО Сбербанк"
|
||||
readOnly={!isEditing}
|
||||
@ -977,7 +1032,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">БИК</Label>
|
||||
<Input
|
||||
value={formData.bik}
|
||||
value={formData.bik || ''}
|
||||
onChange={(e) => handleInputChange('bik', e.target.value)}
|
||||
placeholder="044525225"
|
||||
readOnly={!isEditing}
|
||||
@ -988,7 +1043,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Корр. счет</Label>
|
||||
<Input
|
||||
value={formData.corrAccount}
|
||||
value={formData.corrAccount || ''}
|
||||
onChange={(e) => handleInputChange('corrAccount', e.target.value)}
|
||||
placeholder="30101810400000000225"
|
||||
readOnly={!isEditing}
|
||||
@ -1000,7 +1055,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Расчетный счет</Label>
|
||||
<Input
|
||||
value={formData.accountNumber}
|
||||
value={formData.accountNumber || ''}
|
||||
onChange={(e) => handleInputChange('accountNumber', e.target.value)}
|
||||
placeholder="40702810123456789012"
|
||||
readOnly={!isEditing}
|
||||
@ -1028,14 +1083,16 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
|
||||
<Input
|
||||
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : ''}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
value={isEditing ? (formData.wildberriesApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : '')}
|
||||
onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)}
|
||||
placeholder="Введите API ключ Wildberries"
|
||||
readOnly={!isEditing}
|
||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||
/>
|
||||
{user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') && (
|
||||
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') || (formData.wildberriesApiKey && isEditing)) && (
|
||||
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
API ключ настроен
|
||||
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -1043,14 +1100,16 @@ export function UserSettings() {
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
|
||||
<Input
|
||||
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : ''}
|
||||
readOnly
|
||||
className="glass-input text-white h-10 read-only:opacity-70"
|
||||
value={isEditing ? (formData.ozonApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : '')}
|
||||
onChange={(e) => handleInputChange('ozonApiKey', e.target.value)}
|
||||
placeholder="Введите API ключ Ozon"
|
||||
readOnly={!isEditing}
|
||||
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
|
||||
/>
|
||||
{user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') && (
|
||||
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') || (formData.ozonApiKey && isEditing)) && (
|
||||
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
API ключ настроен
|
||||
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
@ -2,21 +2,18 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Users,
|
||||
Clock,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowUpCircle,
|
||||
ArrowDownCircle
|
||||
} from 'lucide-react'
|
||||
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'
|
||||
|
||||
interface Organization {
|
||||
@ -47,28 +44,43 @@ export function MarketCounterparties() {
|
||||
const { data: outgoingData, loading: outgoingLoading, refetch: refetchOutgoing } = useQuery(GET_OUTGOING_REQUESTS)
|
||||
|
||||
const [respondToRequest] = useMutation(RESPOND_TO_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetchIncoming()
|
||||
refetchCounterparties()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ query: GET_INCOMING_REQUESTS },
|
||||
{ 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, {
|
||||
onCompleted: () => {
|
||||
refetchOutgoing()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ 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, {
|
||||
onCompleted: () => {
|
||||
refetchCounterparties()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ 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) => {
|
||||
try {
|
||||
await respondToRequest({
|
||||
variables: { requestId, response: 'ACCEPTED' }
|
||||
variables: { requestId, accept: true }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка при принятии заявки:', error)
|
||||
@ -78,7 +90,7 @@ export function MarketCounterparties() {
|
||||
const handleRejectRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({
|
||||
variables: { requestId, response: 'REJECTED' }
|
||||
variables: { requestId, accept: false }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отклонении заявки:', error)
|
||||
@ -207,50 +219,72 @@ export function MarketCounterparties() {
|
||||
</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) => (
|
||||
<Card key={request.id} className="glass-card p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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 key={request.id} className="glass-card p-4 w-full">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<OrganizationAvatar organization={request.sender} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-medium">
|
||||
{request.sender.name || request.sender.fullName}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
|
||||
{request.message && (
|
||||
<p className="text-white/80 text-sm mt-2 italic">"{request.message}"</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Clock className="h-3 w-3 text-white/40" />
|
||||
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
|
||||
<div className="flex flex-col space-y-2 mb-3">
|
||||
<h4 className="text-white font-medium text-lg leading-tight">
|
||||
{request.sender.name || request.sender.fullName}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge className={
|
||||
request.sender.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' :
|
||||
request.sender.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
|
||||
request.sender.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
|
||||
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 className="flex space-x-2 ml-4">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@ -269,36 +303,56 @@ export function MarketCounterparties() {
|
||||
</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) => (
|
||||
<Card key={request.id} className="glass-card p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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 key={request.id} className="glass-card p-4 w-full">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<OrganizationAvatar organization={request.receiver} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-medium">
|
||||
{request.receiver.name || request.receiver.fullName}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
|
||||
{request.message && (
|
||||
<p className="text-white/80 text-sm mt-2 italic">"{request.message}"</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-3 w-3 text-white/40" />
|
||||
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
|
||||
<div className="flex flex-col space-y-2 mb-3">
|
||||
<h4 className="text-white font-medium text-lg leading-tight">
|
||||
{request.receiver.name || request.receiver.fullName}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge className={
|
||||
request.receiver.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' :
|
||||
request.receiver.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
|
||||
request.receiver.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
|
||||
request.receiver.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.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>
|
||||
<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>
|
||||
@ -308,13 +362,13 @@ export function MarketCounterparties() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Package } from 'lucide-react'
|
||||
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'
|
||||
|
||||
interface Organization {
|
||||
@ -21,6 +21,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
export function MarketFulfillment() {
|
||||
@ -31,9 +34,15 @@ export function MarketFulfillment() {
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ 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 = () => {
|
||||
@ -44,7 +53,7 @@ export function MarketFulfillment() {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
organizationId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Truck } from 'lucide-react'
|
||||
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'
|
||||
|
||||
interface Organization {
|
||||
@ -21,6 +21,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
export function MarketLogistics() {
|
||||
@ -31,9 +34,15 @@ export function MarketLogistics() {
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ 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 = () => {
|
||||
@ -44,7 +53,7 @@ export function MarketLogistics() {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
organizationId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, ShoppingCart } from 'lucide-react'
|
||||
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'
|
||||
|
||||
interface Organization {
|
||||
@ -21,6 +21,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
export function MarketSellers() {
|
||||
@ -31,9 +34,15 @@ export function MarketSellers() {
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ 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 = () => {
|
||||
@ -44,7 +53,7 @@ export function MarketSellers() {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
organizationId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
|
@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Boxes } from 'lucide-react'
|
||||
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'
|
||||
|
||||
interface Organization {
|
||||
@ -21,6 +21,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
export function MarketWholesale() {
|
||||
@ -31,9 +34,15 @@ export function MarketWholesale() {
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
refetchQueries: [
|
||||
{ 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 = () => {
|
||||
@ -44,7 +53,7 @@ export function MarketWholesale() {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
organizationId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
|
@ -11,7 +11,8 @@ import {
|
||||
Calendar,
|
||||
Plus,
|
||||
Send,
|
||||
Trash2
|
||||
Trash2,
|
||||
User
|
||||
} from 'lucide-react'
|
||||
import { OrganizationAvatar } from './organization-avatar'
|
||||
import { useState } from 'react'
|
||||
@ -28,6 +29,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
isCurrentUser?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
hasIncomingRequest?: boolean
|
||||
}
|
||||
|
||||
interface OrganizationCardProps {
|
||||
@ -144,7 +148,12 @@ export function OrganizationCard({
|
||||
<Badge className={getTypeColor(organization.type)}>
|
||||
{getTypeLabel(organization.type)}
|
||||
</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>
|
||||
@ -190,16 +199,29 @@ export function OrganizationCard({
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Удалить из контрагентов
|
||||
</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}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={organization.isCounterparty}
|
||||
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty)} w-full cursor-pointer`}
|
||||
disabled={organization.isCounterparty || organization.hasOutgoingRequest || organization.hasIncomingRequest}
|
||||
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty || !!organization.hasOutgoingRequest || !!organization.hasIncomingRequest)} w-full cursor-pointer`}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{organization.isCounterparty ? 'Уже добавлен' : actionButtonText}
|
||||
{organization.isCounterparty ? 'Уже добавлен' :
|
||||
organization.hasOutgoingRequest ? 'Заявка отправлена' :
|
||||
organization.hasIncomingRequest ? 'Уже подал заявку' :
|
||||
actionButtonText}
|
||||
</Button>
|
||||
</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)
|
||||
}
|
||||
`
|
||||
|
||||
// Мутации для сообщений
|
||||
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
|
||||
phone
|
||||
avatar
|
||||
managerName
|
||||
createdAt
|
||||
organization {
|
||||
id
|
||||
@ -61,9 +62,13 @@ export const SEARCH_ORGANIZATIONS = gql`
|
||||
emails
|
||||
createdAt
|
||||
isCounterparty
|
||||
isCurrentUser
|
||||
hasOutgoingRequest
|
||||
hasIncomingRequest
|
||||
users {
|
||||
id
|
||||
avatar
|
||||
managerName
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -76,6 +81,7 @@ export const GET_MY_COUNTERPARTIES = gql`
|
||||
inn
|
||||
name
|
||||
fullName
|
||||
managementName
|
||||
type
|
||||
address
|
||||
phones
|
||||
@ -84,6 +90,7 @@ export const GET_MY_COUNTERPARTIES = gql`
|
||||
users {
|
||||
id
|
||||
avatar
|
||||
managerName
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -105,6 +112,11 @@ export const GET_INCOMING_REQUESTS = gql`
|
||||
address
|
||||
phones
|
||||
emails
|
||||
createdAt
|
||||
users {
|
||||
id
|
||||
avatar
|
||||
}
|
||||
}
|
||||
receiver {
|
||||
id
|
||||
@ -112,6 +124,10 @@ export const GET_INCOMING_REQUESTS = gql`
|
||||
name
|
||||
fullName
|
||||
type
|
||||
users {
|
||||
id
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -130,6 +146,10 @@ export const GET_OUTGOING_REQUESTS = gql`
|
||||
name
|
||||
fullName
|
||||
type
|
||||
users {
|
||||
id
|
||||
avatar
|
||||
}
|
||||
}
|
||||
receiver {
|
||||
id
|
||||
@ -140,6 +160,11 @@ export const GET_OUTGOING_REQUESTS = gql`
|
||||
address
|
||||
phones
|
||||
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 where: any = {
|
||||
id: { not: currentUser.organization.id } // Исключаем только собственную организацию
|
||||
// Получаем исходящие заявки для добавления флага hasOutgoingRequest
|
||||
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) {
|
||||
@ -205,10 +227,13 @@ export const resolvers = {
|
||||
}
|
||||
})
|
||||
|
||||
// Добавляем флаг isCounterparty к каждой организации
|
||||
// Добавляем флаги isCounterparty, isCurrentUser, hasOutgoingRequest и hasIncomingRequest к каждой организации
|
||||
return organizations.map(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' }
|
||||
})
|
||||
},
|
||||
|
||||
// Сообщения с контрагентом
|
||||
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 = {
|
||||
})
|
||||
}
|
||||
|
||||
// Создаем организацию селлера - используем название магазина как основное имя
|
||||
const shopName = validationResults[0]?.data?.sellerName || 'Магазин'
|
||||
// Создаем организацию селлера - используем tradeMark как основное имя
|
||||
const tradeMark = validationResults[0]?.data?.tradeMark
|
||||
const sellerName = validationResults[0]?.data?.sellerName
|
||||
const shopName = tradeMark || sellerName || 'Магазин'
|
||||
|
||||
const organization = await prisma.organization.create({
|
||||
data: {
|
||||
inn: validationResults[0]?.data?.inn || `SELLER_${Date.now()}`,
|
||||
name: shopName,
|
||||
fullName: `Интернет-магазин "${shopName}"`,
|
||||
name: shopName, // Используем tradeMark как основное название
|
||||
fullName: sellerName ? `${sellerName} (${shopName})` : `Интернет-магазин "${shopName}"`,
|
||||
type: 'SELLER'
|
||||
}
|
||||
})
|
||||
@ -858,11 +962,19 @@ export const resolvers = {
|
||||
try {
|
||||
const { input } = args
|
||||
|
||||
// Обновляем аватар пользователя если указан
|
||||
// Обновляем данные пользователя (аватар, имя управляющего)
|
||||
const userUpdateData: { avatar?: string; managerName?: string } = {}
|
||||
if (input.avatar) {
|
||||
userUpdateData.avatar = input.avatar
|
||||
}
|
||||
if (input.managerName) {
|
||||
userUpdateData.managerName = input.managerName
|
||||
}
|
||||
|
||||
if (Object.keys(userUpdateData).length > 0) {
|
||||
await prisma.user.update({
|
||||
where: { id: context.user.id },
|
||||
data: { avatar: input.avatar }
|
||||
data: userUpdateData
|
||||
})
|
||||
}
|
||||
|
||||
@ -874,6 +986,9 @@ export const resolvers = {
|
||||
managementPost?: string
|
||||
} = {}
|
||||
|
||||
// Название организации больше не обновляется через профиль
|
||||
// Для селлеров устанавливается при регистрации, для остальных - при смене ИНН
|
||||
|
||||
// Обновляем контактные данные в JSON поле phones
|
||||
if (input.orgPhone) {
|
||||
updateData.phones = [{ value: input.orgPhone, type: 'main' }]
|
||||
@ -898,9 +1013,7 @@ export const resolvers = {
|
||||
}
|
||||
} = {}
|
||||
|
||||
if (input.managerName) {
|
||||
customContacts.managerName = input.managerName
|
||||
}
|
||||
// managerName теперь сохраняется в поле пользователя, а не в JSON
|
||||
|
||||
if (input.telegram) {
|
||||
customContacts.telegram = input.telegram
|
||||
@ -1015,7 +1128,8 @@ export const resolvers = {
|
||||
// Подготавливаем данные для обновления
|
||||
const updateData: Prisma.OrganizationUpdateInput = {
|
||||
kpp: organizationData.kpp,
|
||||
name: organizationData.name,
|
||||
// Для селлеров не обновляем название организации (это название магазина)
|
||||
...(user.organization.type !== 'SELLER' && { name: organizationData.name }),
|
||||
fullName: organizationData.fullName,
|
||||
address: organizationData.address,
|
||||
addressFull: organizationData.addressFull,
|
||||
@ -1023,7 +1137,7 @@ export const resolvers = {
|
||||
ogrnDate: organizationData.ogrnDate ? organizationData.ogrnDate.toISOString() : null,
|
||||
registrationDate: organizationData.registrationDate ? organizationData.registrationDate.toISOString() : null,
|
||||
liquidationDate: organizationData.liquidationDate ? organizationData.liquidationDate.toISOString() : null,
|
||||
managementName: organizationData.managementName,
|
||||
managementName: organizationData.managementName, // Всегда перезаписываем данными из DaData (может быть null)
|
||||
managementPost: user.organization.managementPost, // Сохраняем кастомные данные пользователя
|
||||
opfCode: organizationData.opfCode,
|
||||
opfFull: organizationData.opfFull,
|
||||
@ -1321,6 +1435,317 @@ export const resolvers = {
|
||||
console.error('Error removing counterparty:', error)
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
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!]!
|
||||
|
||||
# Сообщения с контрагентом
|
||||
messages(counterpartyId: ID!, limit: Int, offset: Int): [Message!]!
|
||||
|
||||
# Список чатов (последние сообщения с каждым контрагентом)
|
||||
conversations: [Conversation!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@ -48,6 +54,13 @@ export const typeDefs = gql`
|
||||
respondToCounterpartyRequest(requestId: ID!, accept: Boolean!): CounterpartyRequestResponse!
|
||||
cancelCounterpartyRequest(requestId: 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!
|
||||
phone: String!
|
||||
avatar: String
|
||||
managerName: String
|
||||
organization: Organization
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
@ -92,6 +106,9 @@ export const typeDefs = gql`
|
||||
users: [User!]!
|
||||
apiKeys: [ApiKey!]!
|
||||
isCounterparty: Boolean
|
||||
isCurrentUser: Boolean
|
||||
hasOutgoingRequest: Boolean
|
||||
hasIncomingRequest: Boolean
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
}
|
||||
@ -224,6 +241,46 @@ export const typeDefs = gql`
|
||||
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 скаляр
|
||||
scalar JSON
|
||||
`
|
@ -14,6 +14,7 @@ interface User {
|
||||
id: string
|
||||
phone: string
|
||||
avatar?: string
|
||||
managerName?: string
|
||||
createdAt?: string
|
||||
organization?: {
|
||||
id: string
|
||||
|
@ -6,6 +6,7 @@ export interface MarketplaceValidationResult {
|
||||
data?: {
|
||||
sellerId?: string
|
||||
sellerName?: string
|
||||
tradeMark?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
@ -56,9 +57,9 @@ export class MarketplaceService {
|
||||
isValid: true,
|
||||
message: 'API ключ Wildberries валиден',
|
||||
data: {
|
||||
sellerId: sellerData.id?.toString(),
|
||||
sellerName: sellerData.name || sellerData.supplierName,
|
||||
inn: sellerData.inn
|
||||
sellerId: sellerData.sid, // sid - это уникальный ID продавца
|
||||
sellerName: sellerData.name, // обычное наименование продавца
|
||||
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') {
|
||||
return {
|
||||
isValid: false,
|
||||
|
Reference in New Issue
Block a user