From 205c9eae987606fc5cd277c03c6bc5ffb995c934 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Wed, 16 Jul 2025 22:07:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20?= =?UTF-8?q?=D1=8D=D0=BC=D0=BE=D0=B4=D0=B7=D0=B8=20=D0=B8=20=D1=83=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D0=B0=20=D0=B1=D0=B0=D0=B7=D1=8B=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85.=20=D0=A0=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=81=D0=B5=D0=BD=D0=B4=D0=B6=D0=B5=D1=80?= =?UTF-8?q?=D0=B0.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=BC=D1=83=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D1=87=D0=B0=D1=82=D0=BE=D0=BC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 22 + package.json | 1 + prisma/schema.prisma | 46 ++ src/app/api/upload-file/route.ts | 167 +++++++ src/app/api/upload-voice/route.ts | 82 +++ src/app/messenger/page.tsx | 10 + src/components/auth/auth-flow.tsx | 1 + src/components/auth/confirmation-step.tsx | 17 +- src/components/auth/marketplace-api-step.tsx | 3 + src/components/dashboard/sidebar.tsx | 66 ++- src/components/dashboard/user-settings.tsx | 233 +++++---- .../market/market-counterparties.tsx | 200 +++++--- src/components/market/market-fulfillment.tsx | 19 +- src/components/market/market-logistics.tsx | 19 +- src/components/market/market-sellers.tsx | 19 +- src/components/market/market-wholesale.tsx | 19 +- src/components/market/organization-card.tsx | 32 +- src/components/messenger/messenger-chat.tsx | 432 ++++++++++++++++ .../messenger/messenger-conversations.tsx | 178 +++++++ .../messenger/messenger-dashboard.tsx | 113 +++++ .../messenger/messenger-empty-state.tsx | 48 ++ src/components/ui/emoji-picker.tsx | 68 +++ src/components/ui/file-message.tsx | 108 ++++ src/components/ui/file-uploader.tsx | 216 ++++++++ src/components/ui/image-message.tsx | 129 +++++ src/components/ui/voice-player.tsx | 174 +++++++ src/components/ui/voice-recorder.tsx | 254 ++++++++++ src/graphql/mutations.ts | 191 +++++++ src/graphql/queries.ts | 105 ++++ src/graphql/resolvers.ts | 473 +++++++++++++++++- src/graphql/typedefs.ts | 57 +++ src/hooks/useAuth.ts | 1 + src/services/marketplace-service.ts | 14 +- 33 files changed, 3288 insertions(+), 229 deletions(-) create mode 100644 src/app/api/upload-file/route.ts create mode 100644 src/app/api/upload-voice/route.ts create mode 100644 src/app/messenger/page.tsx create mode 100644 src/components/messenger/messenger-chat.tsx create mode 100644 src/components/messenger/messenger-conversations.tsx create mode 100644 src/components/messenger/messenger-dashboard.tsx create mode 100644 src/components/messenger/messenger-empty-state.tsx create mode 100644 src/components/ui/emoji-picker.tsx create mode 100644 src/components/ui/file-message.tsx create mode 100644 src/components/ui/file-uploader.tsx create mode 100644 src/components/ui/image-message.tsx create mode 100644 src/components/ui/voice-player.tsx create mode 100644 src/components/ui/voice-recorder.tsx diff --git a/package-lock.json b/package-lock.json index 40af6c6..f6b771d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e95e59d..46096b1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3e27f7d..0226bdb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/src/app/api/upload-file/route.ts b/src/app/api/upload-file/route.ts new file mode 100644 index 0000000..de0f379 --- /dev/null +++ b/src/app/api/upload-file/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/upload-voice/route.ts b/src/app/api/upload-voice/route.ts new file mode 100644 index 0000000..8a6fff3 --- /dev/null +++ b/src/app/api/upload-voice/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/src/app/messenger/page.tsx b/src/app/messenger/page.tsx new file mode 100644 index 0000000..e2a0420 --- /dev/null +++ b/src/app/messenger/page.tsx @@ -0,0 +1,10 @@ +import { AuthGuard } from "@/components/auth-guard" +import { MessengerDashboard } from "@/components/messenger/messenger-dashboard" + +export default function MessengerPage() { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/components/auth/auth-flow.tsx b/src/components/auth/auth-flow.tsx index 78845fb..ea3bfab 100644 --- a/src/components/auth/auth-flow.tsx +++ b/src/components/auth/auth-flow.tsx @@ -23,6 +23,7 @@ interface OrganizationData { interface ApiKeyValidation { sellerId?: string sellerName?: string + tradeMark?: string isValid?: boolean } diff --git a/src/components/auth/confirmation-step.tsx b/src/components/auth/confirmation-step.tsx index 822eff7..403145f 100644 --- a/src/components/auth/confirmation-step.tsx +++ b/src/components/auth/confirmation-step.tsx @@ -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 - {data.wbApiValidation?.sellerName ? ( + {data.wbApiValidation?.tradeMark || data.wbApiValidation?.sellerName ? ( - {data.wbApiValidation.sellerName} + {data.wbApiValidation.tradeMark || data.wbApiValidation.sellerName} ) : ( @@ -263,9 +264,17 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr {data.wbApiValidation && ( <> - {data.wbApiValidation.sellerName && ( + {data.wbApiValidation.tradeMark && (
- Магазин: + Торговая марка: + + {data.wbApiValidation.tradeMark} + +
+ )} + {data.wbApiValidation.sellerName && data.wbApiValidation.sellerName !== data.wbApiValidation.tradeMark && ( +
+ Продавец: {data.wbApiValidation.sellerName} diff --git a/src/components/auth/marketplace-api-step.tsx b/src/components/auth/marketplace-api-step.tsx index 50970f3..3edfbc8 100644 --- a/src/components/auth/marketplace-api-step.tsx +++ b/src/components/auth/marketplace-api-step.tsx @@ -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 }) } diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index c9f5266..d8fefa3 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -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 (
@@ -67,30 +73,33 @@ export function Sidebar() { {/* Информация о пользователе */} - -
- - {user?.avatar ? ( - - ) : null} - - {getInitials()} - - + +
+
+ + {user?.avatar ? ( + + ) : null} + + {getInitials()} + + +
+
-
- -

- {getOrganizationName()} +

+ {getOrganizationName()} +

+
+
+

+ {getCabinetType()}

-

- {getCabinetType()} -

@@ -109,6 +118,19 @@ export function Sidebar() { Маркет + +
{/* Компактный индикатор прогресса */} - {isIncomplete && ( -
-
- {profileStatus.percentage}% -
-
- Осталось {profileStatus.missingFields.length} { - profileStatus.missingFields.length === 1 ? 'поле' : - profileStatus.missingFields.length < 5 ? 'поля' : 'полей' - } -
+
+
+ {profileStatus.percentage}%
- )} +
+ {isIncomplete ? ( + <>Заполнено {profileStatus.percentage}% профиля + ) : ( + <>Профиль полностью заполнен + )} +
+
{isEditing ? ( <> @@ -590,10 +635,10 @@ 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') && ( - Финансовые + Финансы )} {user?.organization?.type === 'SELLER' && ( @@ -602,10 +647,12 @@ export function UserSettings() { API )} - - - Инструменты - + {user?.organization?.type !== 'SELLER' && ( + + + Инструменты + + )} {/* Профиль пользователя */} @@ -671,7 +718,7 @@ export function UserSettings() {
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) && (

{getFieldError('orgPhone', formData.orgPhone)}

- ) : !formData.orgPhone && ( -

- - Рекомендуется указать -

)}
handleInputChange('managerName', e.target.value)} placeholder="Иван Иванов" readOnly={!isEditing} @@ -719,7 +761,7 @@ export function UserSettings() { Telegram handleInputChange('telegram', e.target.value)} placeholder="@username" readOnly={!isEditing} @@ -741,7 +783,7 @@ export function UserSettings() { WhatsApp handleInputChange('whatsapp', e.target.value)} placeholder="+7 (999) 999-99-99" readOnly={!isEditing} @@ -764,7 +806,7 @@ export function UserSettings() { handleInputChange('email', e.target.value)} placeholder="example@company.com" readOnly={!isEditing} @@ -807,14 +849,25 @@ export function UserSettings() { {/* Названия */}
- + 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' ? ( +

+ Название устанавливается при регистрации кабинета и не может быть изменено. +

+ ) : ( +

+ Автоматически заполняется из федерального реестра при указании ИНН. +

+ )}
@@ -904,27 +957,29 @@ export function UserSettings() { {/* Руководитель и статус */}
- {user?.organization?.managementName && ( -
- - -
- )} +
+ + +

+ {user?.organization?.managementName + ? 'Данные из федерального реестра' + : 'Автоматически заполняется из федерального реестра при указании ИНН'} +

+
- {user?.organization?.status && ( -
- - -
- )} +
+ + +
{/* Дата регистрации */} @@ -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') && (
@@ -965,7 +1020,7 @@ export function UserSettings() {
handleInputChange('bankName', e.target.value)} placeholder="ПАО Сбербанк" readOnly={!isEditing} @@ -977,7 +1032,7 @@ export function UserSettings() {
handleInputChange('bik', e.target.value)} placeholder="044525225" readOnly={!isEditing} @@ -988,7 +1043,7 @@ export function UserSettings() {
handleInputChange('corrAccount', e.target.value)} placeholder="30101810400000000225" readOnly={!isEditing} @@ -1000,7 +1055,7 @@ export function UserSettings() {
handleInputChange('accountNumber', e.target.value)} placeholder="40702810123456789012" readOnly={!isEditing} @@ -1028,14 +1083,16 @@ export function UserSettings() {
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)) && (

- API ключ настроен + {!isEditing ? 'API ключ настроен' : 'Будет сохранен'}

)}
@@ -1043,14 +1100,16 @@ export function UserSettings() {
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)) && (

- API ключ настроен + {!isEditing ? 'API ключ настроен' : 'Будет сохранен'}

)}
diff --git a/src/components/market/market-counterparties.tsx b/src/components/market/market-counterparties.tsx index 577f6df..8869e23 100644 --- a/src/components/market/market-counterparties.tsx +++ b/src/components/market/market-counterparties.tsx @@ -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() {
) : ( -
+
{incomingRequests.map((request: CounterpartyRequest) => ( - -
-
-
-
- {(request.sender.name || request.sender.fullName || 'O').charAt(0).toUpperCase()} -
-
+
+
+
+
-

- {request.sender.name || request.sender.fullName} -

-

ИНН: {request.sender.inn}

- {request.message && ( -

"{request.message}"

- )} -
- - {formatDate(request.createdAt)} +
+

+ {request.sender.name || request.sender.fullName} +

+
+ + {request.sender.type === 'FULFILLMENT' ? 'Фулфилмент' : + request.sender.type === 'SELLER' ? 'Селлер' : + request.sender.type === 'LOGIST' ? 'Логистика' : + request.sender.type === 'WHOLESALE' ? 'Оптовик' : + request.sender.type} + +
+
+ +
+

ИНН: {request.sender.inn}

+ {request.sender.address && ( +
+ {request.sender.address} +
+ )} + {request.message && ( +
+

"{request.message}"

+
+ )} +
+ Заявка от {formatDate(request.createdAt)} +
-
+
- +
))}
)} @@ -269,36 +303,56 @@ export function MarketCounterparties() {
) : ( -
+
{outgoingRequests.map((request: CounterpartyRequest) => ( - -
-
-
-
- {(request.receiver.name || request.receiver.fullName || 'O').charAt(0).toUpperCase()} -
-
+
+
+
+
-

- {request.receiver.name || request.receiver.fullName} -

-

ИНН: {request.receiver.inn}

- {request.message && ( -

"{request.message}"

- )} -
-
- - {formatDate(request.createdAt)} +
+

+ {request.receiver.name || request.receiver.fullName} +

+
+ + {request.receiver.type === 'FULFILLMENT' ? 'Фулфилмент' : + request.receiver.type === 'SELLER' ? 'Селлер' : + request.receiver.type === 'LOGIST' ? 'Логистика' : + request.receiver.type === 'WHOLESALE' ? 'Оптовик' : + request.receiver.type} + + + {request.status === 'PENDING' ? 'Ожидает ответа' : request.status === 'REJECTED' ? 'Отклонено' : request.status} + +
+
+ +
+

ИНН: {request.receiver.inn}

+ {request.receiver.address && ( +
+ {request.receiver.address} +
+ )} + {request.message && ( +
+

"{request.message}"

+
+ )} +
+ Отправлено {formatDate(request.createdAt)}
- - {request.status === 'PENDING' ? 'Ожидает' : request.status === 'REJECTED' ? 'Отклонено' : request.status} -
@@ -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" > - + Отменить заявку )}
- +
))}
)} diff --git a/src/components/market/market-fulfillment.tsx b/src/components/market/market-fulfillment.tsx index 0a44a92..7fd492a 100644 --- a/src/components/market/market-fulfillment.tsx +++ b/src/components/market/market-fulfillment.tsx @@ -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 || 'Заявка на добавление в контрагенты' } }) diff --git a/src/components/market/market-logistics.tsx b/src/components/market/market-logistics.tsx index 7199bac..55df860 100644 --- a/src/components/market/market-logistics.tsx +++ b/src/components/market/market-logistics.tsx @@ -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 || 'Заявка на добавление в контрагенты' } }) diff --git a/src/components/market/market-sellers.tsx b/src/components/market/market-sellers.tsx index 3d0d5b9..a8abecc 100644 --- a/src/components/market/market-sellers.tsx +++ b/src/components/market/market-sellers.tsx @@ -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 || 'Заявка на добавление в контрагенты' } }) diff --git a/src/components/market/market-wholesale.tsx b/src/components/market/market-wholesale.tsx index 1bbbe80..69e93e2 100644 --- a/src/components/market/market-wholesale.tsx +++ b/src/components/market/market-wholesale.tsx @@ -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 || 'Заявка на добавление в контрагенты' } }) diff --git a/src/components/market/organization-card.tsx b/src/components/market/organization-card.tsx index 9e1b009..72468ac 100644 --- a/src/components/market/organization-card.tsx +++ b/src/components/market/organization-card.tsx @@ -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({ {getTypeLabel(organization.type)} - {organization.isCounterparty && ( + {organization.isCurrentUser && ( + + Это вы + + )} + {organization.isCounterparty && !organization.isCurrentUser && ( Уже добавлен @@ -190,16 +199,29 @@ export function OrganizationCard({ Удалить из контрагентов + ) : organization.isCurrentUser ? ( + ) : ( diff --git a/src/components/messenger/messenger-chat.tsx b/src/components/messenger/messenger-chat.tsx new file mode 100644 index 0000000..8fc1ad2 --- /dev/null +++ b/src/components/messenger/messenger-chat.tsx @@ -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(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 ( +
+ {/* Заголовок чата */} +
+
+ + {counterparty.users?.[0]?.avatar ? ( + + ) : null} + + {getInitials(counterparty)} + + + +
+

+ {getOrganizationName(counterparty)} +

+

+ {getManagerName(counterparty)} +

+
+ + {getTypeLabel(counterparty.type)} + +
+
+
+ + +
+ + {/* Область сообщений */} +
+ {loading ? ( +
+
Загрузка сообщений...
+
+ ) : messages.length === 0 ? ( +
+
+
+ +
+

Начните беседу

+

+ Отправьте первое сообщение {getOrganizationName(counterparty)} +

+
+
+ ) : ( + 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 ( +
+ {showDate && ( +
+
+ + {formatDate(msg.createdAt)} + +
+
+ )} + +
+
+ {/* Имя отправителя */} + {!isCurrentUser && ( +
+ + {msg.senderOrganization?.users?.[0]?.avatar ? ( + + ) : null} + + {getInitials(msg.senderOrganization)} + + + + {getManagerName(msg.senderOrganization)} + + + {getTypeLabel(msg.senderOrganization.type)} + +
+ )} + + {/* Сообщение */} +
+ {msg.type === 'VOICE' && msg.voiceUrl ? ( + + ) : msg.type === 'IMAGE' && msg.fileUrl ? ( + + ) : msg.type === 'FILE' && msg.fileUrl ? ( + + ) : msg.content ? ( +
+

{msg.content}

+
+ ) : null} + +

+ {formatTime(msg.createdAt)} +

+
+
+
+
+ ) + }) + )} +
+
+ + {/* Поле ввода сообщения */} +
+
+
+ setMessage(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Введите сообщение..." + className="glass-input text-white placeholder:text-white/40 flex-1" + /> + + + +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/messenger/messenger-conversations.tsx b/src/components/messenger/messenger-conversations.tsx new file mode 100644 index 0000000..3dc6058 --- /dev/null +++ b/src/components/messenger/messenger-conversations.tsx @@ -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 ( +
+
Загрузка...
+
+ ) + } + + return ( +
+ {/* Заголовок */} +
+ +
+

Контрагенты

+

{counterparties.length} активных

+
+
+ + {/* Поиск */} +
+ + setSearchTerm(e.target.value)} + className="glass-input text-white placeholder:text-white/40 pl-10 h-10" + /> +
+ + {/* Список контрагентов */} +
+ {filteredCounterparties.length === 0 ? ( +
+
+ +
+

+ {searchTerm ? 'Ничего не найдено' : 'Контрагенты не найдены'} +

+
+ ) : ( + filteredCounterparties.map((org) => ( +
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' + }`} + > +
+ + {org.users?.[0]?.avatar ? ( + + ) : null} + + {getInitials(org)} + + + +
+

+ {getOrganizationName(org)} +

+ +
+ + {getTypeLabel(org.type)} + +
+ +

+ {getManagerName(org)} +

+
+
+
+ )) + )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/messenger/messenger-dashboard.tsx b/src/components/messenger/messenger-dashboard.tsx new file mode 100644 index 0000000..5613597 --- /dev/null +++ b/src/components/messenger/messenger-dashboard.tsx @@ -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(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 ( +
+ +
+
+
+
+

Мессенджер

+

Общение с контрагентами

+
+
+ +
+ + + +
+
+
+
+ ) + } + + return ( +
+ +
+
+ {/* Заголовок - фиксированная высота */} +
+
+

Мессенджер

+

Общение с контрагентами

+
+
+ + {/* Основной контент - сетка из 2 колонок */} +
+
+ {/* Левая колонка - список контрагентов */} + + + + + {/* Правая колонка - чат */} + + {selectedCounterparty && selectedCounterpartyData ? ( + + ) : ( +
+
+
+ +
+

Выберите контрагента

+

+ Начните беседу с одним из ваших контрагентов +

+
+
+ )} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/messenger/messenger-empty-state.tsx b/src/components/messenger/messenger-empty-state.tsx new file mode 100644 index 0000000..0b349c6 --- /dev/null +++ b/src/components/messenger/messenger-empty-state.tsx @@ -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 ( +
+
+
+ +
+ +

+ У вас пока нет контрагентов +

+ +

+ Чтобы начать общение, сначала найдите и добавьте контрагентов в разделе «Маркет». + После добавления они появятся здесь для общения. +

+ +
+ + +
+ + Найдите партнеров и начните общение +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/emoji-picker.tsx b/src/components/ui/emoji-picker.tsx new file mode 100644 index 0000000..b586b19 --- /dev/null +++ b/src/components/ui/emoji-picker.tsx @@ -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(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 ( +
+ + + {showPicker && ( +
+
+ +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/file-message.tsx b/src/components/ui/file-message.tsx new file mode 100644 index 0000000..26eaa58 --- /dev/null +++ b/src/components/ui/file-message.tsx @@ -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 + + if (type.includes('pdf')) return + if (type.includes('zip') || type.includes('archive')) return + if (type.includes('image')) return + if (type.includes('word') || type.includes('document')) return + if (type.includes('excel') || type.includes('spreadsheet')) return + if (type.includes('powerpoint') || type.includes('presentation')) return + + return + } + + 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 ( +
+ + {/* Иконка файла */} +
+ {getFileIcon(fileType)} +
+ + {/* Информация о файле */} +
+

+ {fileName} +

+ +
+ {fileSize && ( + + {formatFileSize(fileSize)} + + )} + + + {getFileExtension(fileName)} + +
+
+ + {/* Кнопка скачивания */} + +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/file-uploader.tsx b/src/components/ui/file-uploader.tsx new file mode 100644 index 0000000..68e719c --- /dev/null +++ b/src/components/ui/file-uploader.tsx @@ -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(null) + const fileInputRef = useRef(null) + const imageInputRef = useRef(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, 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 ( +
+
+ {isImageType(selectedFile.type) ? ( +
+ Preview +
+

{selectedFile.name}

+

{formatFileSize(selectedFile.size)}

+
+
+ ) : ( +
+
+ +
+
+

{selectedFile.name}

+

{formatFileSize(selectedFile.size)}

+
+
+ )} +
+ +
+ + +
+
+ ) + } + + return ( +
+ {/* Кнопка для загрузки изображений */} + + + {/* Кнопка для загрузки файлов */} + + + {/* Скрытые input элементы */} + handleFileInputChange(e, 'IMAGE')} + className="hidden" + /> + + handleFileInputChange(e, 'FILE')} + className="hidden" + /> + + {isUploading && ( +
+ Загрузка... +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/image-message.tsx b/src/components/ui/image-message.tsx new file mode 100644 index 0000000..b96cc81 --- /dev/null +++ b/src/components/ui/image-message.tsx @@ -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 ( + <> +
+
+ {fileName} setIsLoading(false)} + onClick={handleImageClick} + /> + + {isLoading && ( +
+
Загрузка...
+
+ )} + + {/* Overlay с кнопками при наведении */} +
+ + +
+
+ + {/* Информация о файле */} +
+

+ {fileName} +

+ {fileSize && ( +

+ {formatFileSize(fileSize)} +

+ )} +
+
+ + {/* Полноэкранный просмотр */} + {showFullSize && ( +
setShowFullSize(false)} + > +
+ {fileName} e.stopPropagation()} + /> + +
+
+ )} + + ) +} \ No newline at end of file diff --git a/src/components/ui/voice-player.tsx b/src/components/ui/voice-player.tsx new file mode 100644 index 0000000..559cea3 --- /dev/null +++ b/src/components/ui/voice-player.tsx @@ -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(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 ( +
+ {/* Кнопка воспроизведения */} + + + {/* Визуализация волны / прогресс бар */} +
+
+ +
+
+
+
+ + {/* Время */} +
+ + {formatTime(currentTime)} + + + {formatTime(audioDuration)} + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/voice-recorder.tsx b/src/components/ui/voice-recorder.tsx new file mode 100644 index 0000000..76218ba --- /dev/null +++ b/src/components/ui/voice-recorder.tsx @@ -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(null) + const [duration, setDuration] = useState(0) + const [isPlaying, setIsPlaying] = useState(false) + const [permission, setPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt') + + const mediaRecorderRef = useRef(null) + const audioChunksRef = useRef([]) + const audioRef = useRef(null) + const intervalRef = useRef(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 ( +
+ Доступ к микрофону запрещен +
+ ) + } + + return ( +
+ {!recordedAudio ? ( + // Состояние записи + <> + {!isRecording ? ( + + ) : ( + <> +
+
+ + {formatDuration(duration)} + + + +
+ + )} + + ) : ( + // Состояние воспроизведения и отправки +
+ + + {formatDuration(duration)} + + + +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index a06ce60..7df648c 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -355,4 +355,195 @@ export const REMOVE_COUNTERPARTY = gql` mutation RemoveCounterparty($organizationId: ID!) { 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) + } ` \ No newline at end of file diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 29c0293..dd789fc 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -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 + } } } } @@ -166,4 +191,84 @@ export const GET_ORGANIZATION = gql` updatedAt } } +` + +// Запросы для сообщений +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 + } + } ` \ No newline at end of file diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index faf9ddc..e10f5e3 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -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 = { + // Больше не исключаем собственную организацию } 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 + } } } \ No newline at end of file diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 53a21d2..1118ea1 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -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 ` \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 910346a..51e1e6c 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -14,6 +14,7 @@ interface User { id: string phone: string avatar?: string + managerName?: string createdAt?: string organization?: { id: string diff --git a/src/services/marketplace-service.ts b/src/services/marketplace-service.ts index 5badd2f..f657510 100644 --- a/src/services/marketplace-service.ts +++ b/src/services/marketplace-service.ts @@ -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,