Добавлены новые зависимости для работы с эмодзи и улучшена структура базы данных. Реализована модель сообщений и обновлены компоненты для поддержки новых функций мессенджера. Обновлены запросы и мутации для работы с сообщениями и чатом.

This commit is contained in:
Bivekich
2025-07-16 22:07:38 +03:00
parent 823ef9a28c
commit 205c9eae98
33 changed files with 3288 additions and 229 deletions

22
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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>
)
}

View File

@ -23,6 +23,7 @@ interface OrganizationData {
interface ApiKeyValidation {
sellerId?: string
sellerName?: string
tradeMark?: string
isValid?: boolean
}

View File

@ -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>

View File

@ -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
})
}

View File

@ -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 ${

View File

@ -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>

View File

@ -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">&quot;{request.message}&quot;</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">&quot;{request.message}&quot;</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">&quot;{request.message}&quot;</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">&quot;{request.message}&quot;</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>
)}

View File

@ -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 || 'Заявка на добавление в контрагенты'
}
})

View File

@ -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 || 'Заявка на добавление в контрагенты'
}
})

View File

@ -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 || 'Заявка на добавление в контрагенты'
}
})

View File

@ -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 || 'Заявка на добавление в контрагенты'
}
})

View File

@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)}
</>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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)
}
`

View File

@ -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
}
}
`

View File

@ -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
}
}
}

View File

@ -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
`

View File

@ -14,6 +14,7 @@ interface User {
id: string
phone: string
avatar?: string
managerName?: string
createdAt?: string
organization?: {
id: string

View File

@ -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,