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

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

View File

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

View File

@ -17,6 +17,7 @@ interface OrganizationData {
interface ApiKeyValidation {
sellerId?: string
sellerName?: string
tradeMark?: string
isValid?: boolean
}
@ -249,9 +250,9 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr
WB
</Badge>
</div>
{data.wbApiValidation?.sellerName ? (
{data.wbApiValidation?.tradeMark || data.wbApiValidation?.sellerName ? (
<span className="text-white/70 text-xs max-w-[120px] text-right truncate">
{data.wbApiValidation.sellerName}
{data.wbApiValidation.tradeMark || data.wbApiValidation.sellerName}
</span>
) : (
<Badge variant="outline" className="glass-secondary text-green-300 border-green-400/30 text-xs flex items-center gap-1">
@ -263,9 +264,17 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr
{data.wbApiValidation && (
<>
{data.wbApiValidation.sellerName && (
{data.wbApiValidation.tradeMark && (
<div className="flex items-center justify-between pl-4">
<span className="text-white/50 text-xs">Магазин:</span>
<span className="text-white/50 text-xs">Торговая марка:</span>
<span className="text-white/70 text-xs max-w-[160px] text-right truncate">
{data.wbApiValidation.tradeMark}
</span>
</div>
)}
{data.wbApiValidation.sellerName && data.wbApiValidation.sellerName !== data.wbApiValidation.tradeMark && (
<div className="flex items-center justify-between pl-4">
<span className="text-white/50 text-xs">Продавец:</span>
<span className="text-white/70 text-xs max-w-[160px] text-right truncate">
{data.wbApiValidation.sellerName}
</span>

View File

@ -15,6 +15,7 @@ import { getAuthToken } from '@/lib/apollo-client'
interface ApiValidationData {
sellerId?: string
sellerName?: string
tradeMark?: string
isValid?: boolean
}
@ -97,12 +98,14 @@ export function MarketplaceApiStep({ onNext, onBack }: MarketplaceApiStepProps)
setWbValidationData({
sellerId: validationData.sellerId,
sellerName: validationData.sellerName,
tradeMark: validationData.tradeMark,
isValid: true
})
} else if (marketplace === 'ozon') {
setOzonValidationData({
sellerId: validationData.sellerId,
sellerName: validationData.sellerName,
tradeMark: validationData.tradeMark,
isValid: true
})
}

View File

@ -10,7 +10,8 @@ import {
Settings,
LogOut,
Building2,
Store
Store,
MessageCircle
} from 'lucide-react'
export function Sidebar() {
@ -58,8 +59,13 @@ export function Sidebar() {
router.push('/market')
}
const handleMessengerClick = () => {
router.push('/messenger')
}
const isSettingsActive = pathname === '/settings'
const isMarketActive = pathname.startsWith('/market')
const isMessengerActive = pathname.startsWith('/messenger')
return (
<div className="fixed left-0 top-0 h-full w-56 bg-white/10 backdrop-blur-xl border-r border-white/20 p-3">
@ -67,30 +73,33 @@ export function Sidebar() {
{/* Информация о пользователе */}
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 mb-4">
<div className="flex items-center space-x-2">
<Avatar className="h-10 w-10">
{user?.avatar ? (
<AvatarImage
src={user.avatar}
alt="Аватар пользователя"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials()}
</AvatarFallback>
</Avatar>
<Card className="bg-gradient-to-br from-white/15 to-white/5 backdrop-blur border border-white/30 p-4 mb-4 shadow-lg">
<div className="flex items-center space-x-3">
<div className="relative">
<Avatar className="h-12 w-12 flex-shrink-0 ring-2 ring-white/20">
{user?.avatar ? (
<AvatarImage
src={user.avatar}
alt="Аватар пользователя"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-sm font-semibold">
{getInitials()}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-400 rounded-full border-2 border-white/20"></div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-1 mb-1">
<Building2 className="h-3 w-3 text-white/60" />
<p className="text-white text-xs font-medium truncate">
{getOrganizationName()}
<p className="text-white text-sm font-semibold truncate mb-1" title={getOrganizationName()}>
{getOrganizationName()}
</p>
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-purple-400 rounded-full flex-shrink-0"></div>
<p className="text-white/70 text-xs font-medium">
{getCabinetType()}
</p>
</div>
<p className="text-white/60 text-xs truncate">
{getCabinetType()}
</p>
</div>
</div>
</Card>
@ -109,6 +118,19 @@ export function Sidebar() {
<Store className="h-3 w-3 mr-2" />
Маркет
</Button>
<Button
variant={isMessengerActive ? "secondary" : "ghost"}
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
isMessengerActive
? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/80 hover:bg-white/10 hover:text-white'
} cursor-pointer`}
onClick={handleMessengerClick}
>
<MessageCircle className="h-3 w-3 mr-2" />
Мессенджер
</Button>
<Button
variant={isSettingsActive ? "secondary" : "ghost"}

View File

@ -70,7 +70,11 @@ export function UserSettings() {
bankName: '',
bik: '',
accountNumber: '',
corrAccount: ''
corrAccount: '',
// API ключи маркетплейсов
wildberriesApiKey: '',
ozonApiKey: ''
})
// Загружаем данные организации при монтировании компонента
@ -122,7 +126,7 @@ export function UserSettings() {
setFormData({
orgPhone: orgPhone,
managerName: customContacts?.managerName || '',
managerName: user?.managerName || '',
telegram: customContacts?.telegram || '',
whatsapp: customContacts?.whatsapp || '',
email: email,
@ -135,7 +139,9 @@ export function UserSettings() {
bankName: customContacts?.bankDetails?.bankName || '',
bik: customContacts?.bankDetails?.bik || '',
accountNumber: customContacts?.bankDetails?.accountNumber || '',
corrAccount: customContacts?.bankDetails?.corrAccount || ''
corrAccount: customContacts?.bankDetails?.corrAccount || '',
wildberriesApiKey: '',
ozonApiKey: ''
})
}
}, [user])
@ -176,8 +182,8 @@ export function UserSettings() {
// Дополнительные поля в зависимости от типа кабинета
const additionalFields = []
if (user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') {
// Финансовые данные - всегда обязательны для бизнес-кабинетов
if (user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') {
// Финансовые данные - всегда обязательны для всех типов кабинетов
additionalFields.push(
{ field: 'bankName', label: 'Название банка', value: formData.bankName },
{ field: 'bik', label: 'БИК', value: formData.bik },
@ -386,13 +392,30 @@ export function UserSettings() {
const cleaned = value.replace(/\D/g, '')
return cleaned.length !== 11 ? 'Неверный формат телефона' : null
case 'telegram':
return value.length < 6 ? 'Минимум 5 символов после @' : null
// Проверяем что после @ есть минимум 5 символов
const usernameLength = value.startsWith('@') ? value.length - 1 : value.length
return usernameLength < 5 ? 'Минимум 5 символов после @' : null
case 'inn':
// Игнорируем автоматически сгенерированные ИНН селлеров
if (value.startsWith('SELLER_')) {
return null
}
const innCleaned = value.replace(/\D/g, '')
if (innCleaned.length !== 10 && innCleaned.length !== 12) {
return 'ИНН должен содержать 10 или 12 цифр'
}
return null
case 'bankName':
return value.trim().length < 3 ? 'Минимум 3 символа' : null
case 'bik':
const bikCleaned = value.replace(/\D/g, '')
return bikCleaned.length !== 9 ? 'БИК должен содержать 9 цифр' : null
case 'accountNumber':
const accountCleaned = value.replace(/\D/g, '')
return accountCleaned.length !== 20 ? 'Расчетный счет должен содержать 20 цифр' : null
case 'corrAccount':
const corrCleaned = value.replace(/\D/g, '')
return corrCleaned.length !== 20 ? 'Корр. счет должен содержать 20 цифр' : null
default:
return null
}
@ -400,11 +423,20 @@ export function UserSettings() {
// Проверка наличия ошибок валидации
const hasValidationErrors = () => {
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn']
return fields.some(field => {
const fields = ['orgPhone', 'managerName', 'telegram', 'whatsapp', 'email', 'inn', 'bankName', 'bik', 'accountNumber', 'corrAccount']
// Проверяем ошибки валидации только в заполненных полях
const hasErrors = fields.some(field => {
const value = formData[field as keyof typeof formData]
return getFieldError(field, value)
// Проверяем ошибки только для заполненных полей
if (!value || !value.trim()) return false
const error = getFieldError(field, value)
return error !== null
})
// Убираем проверку обязательных полей - пользователь может заполнять постепенно
return hasErrors
}
const handleSave = async () => {
@ -437,19 +469,33 @@ export function UserSettings() {
setSaveMessage({ type: 'success', text: 'Данные организации обновлены. Сохраняем профиль...' })
}
// Подготавливаем только заполненные поля для отправки
const inputData: {
orgPhone?: string
managerName?: string
telegram?: string
whatsapp?: string
email?: string
bankName?: string
bik?: string
accountNumber?: string
corrAccount?: string
} = {}
// orgName больше не редактируется - устанавливается только при регистрации
if (formData.orgPhone?.trim()) inputData.orgPhone = formData.orgPhone.trim()
if (formData.managerName?.trim()) inputData.managerName = formData.managerName.trim()
if (formData.telegram?.trim()) inputData.telegram = formData.telegram.trim()
if (formData.whatsapp?.trim()) inputData.whatsapp = formData.whatsapp.trim()
if (formData.email?.trim()) inputData.email = formData.email.trim()
if (formData.bankName?.trim()) inputData.bankName = formData.bankName.trim()
if (formData.bik?.trim()) inputData.bik = formData.bik.trim()
if (formData.accountNumber?.trim()) inputData.accountNumber = formData.accountNumber.trim()
if (formData.corrAccount?.trim()) inputData.corrAccount = formData.corrAccount.trim()
const result = await updateUserProfile({
variables: {
input: {
orgPhone: formData.orgPhone,
managerName: formData.managerName,
telegram: formData.telegram,
whatsapp: formData.whatsapp,
email: formData.email,
bankName: formData.bankName,
bik: formData.bik,
accountNumber: formData.accountNumber,
corrAccount: formData.corrAccount
}
input: inputData
}
})
@ -516,19 +562,18 @@ export function UserSettings() {
</div>
<div className="flex items-center gap-2">
{/* Компактный индикатор прогресса */}
{isIncomplete && (
<div className="flex items-center gap-2 mr-2">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
</div>
<div className="hidden sm:block text-xs text-white/70">
Осталось {profileStatus.missingFields.length} {
profileStatus.missingFields.length === 1 ? 'поле' :
profileStatus.missingFields.length < 5 ? 'поля' : 'полей'
}
</div>
<div className="flex items-center gap-2 mr-2">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
<span className="text-xs text-white font-medium">{profileStatus.percentage}%</span>
</div>
)}
<div className="hidden sm:block text-xs text-white/70">
{isIncomplete ? (
<>Заполнено {profileStatus.percentage}% профиля</>
) : (
<>Профиль полностью заполнен</>
)}
</div>
</div>
{isEditing ? (
<>
@ -590,10 +635,10 @@ export function UserSettings() {
<Building2 className="h-4 w-4 mr-2" />
Организация
</TabsTrigger>
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
<TabsTrigger value="financial" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<CreditCard className="h-4 w-4 mr-2" />
Финансовые
Финансы
</TabsTrigger>
)}
{user?.organization?.type === 'SELLER' && (
@ -602,10 +647,12 @@ export function UserSettings() {
API
</TabsTrigger>
)}
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<Settings className="h-4 w-4 mr-2" />
Инструменты
</TabsTrigger>
{user?.organization?.type !== 'SELLER' && (
<TabsTrigger value="tools" className="text-white data-[state=active]:bg-white/20 cursor-pointer">
<Settings className="h-4 w-4 mr-2" />
Инструменты
</TabsTrigger>
)}
</TabsList>
{/* Профиль пользователя */}
@ -671,7 +718,7 @@ export function UserSettings() {
<div>
<Label className="text-white/80 text-sm mb-2 block">Номер телефона организации</Label>
<Input
value={formData.orgPhone}
value={formData.orgPhone || ''}
onChange={(e) => handleInputChange('orgPhone', e.target.value)}
placeholder="+7 (999) 999-99-99"
readOnly={!isEditing}
@ -679,23 +726,18 @@ export function UserSettings() {
getFieldError('orgPhone', formData.orgPhone) ? 'border-red-400' : ''
}`}
/>
{getFieldError('orgPhone', formData.orgPhone) ? (
{getFieldError('orgPhone', formData.orgPhone) && (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{getFieldError('orgPhone', formData.orgPhone)}
</p>
) : !formData.orgPhone && (
<p className="text-orange-400 text-xs mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Рекомендуется указать
</p>
)}
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Имя управляющего</Label>
<Input
value={formData.managerName}
value={formData.managerName || ''}
onChange={(e) => handleInputChange('managerName', e.target.value)}
placeholder="Иван Иванов"
readOnly={!isEditing}
@ -719,7 +761,7 @@ export function UserSettings() {
Telegram
</Label>
<Input
value={formData.telegram}
value={formData.telegram || ''}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
readOnly={!isEditing}
@ -741,7 +783,7 @@ export function UserSettings() {
WhatsApp
</Label>
<Input
value={formData.whatsapp}
value={formData.whatsapp || ''}
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
placeholder="+7 (999) 999-99-99"
readOnly={!isEditing}
@ -764,7 +806,7 @@ export function UserSettings() {
</Label>
<Input
type="email"
value={formData.email}
value={formData.email || ''}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="example@company.com"
readOnly={!isEditing}
@ -807,14 +849,25 @@ export function UserSettings() {
{/* Названия */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Название организации</Label>
<Label className="text-white/80 text-sm mb-2 block">
{user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
</Label>
<Input
value={formData.orgName || user?.organization?.name || ''}
onChange={(e) => handleInputChange('orgName', e.target.value)}
placeholder="Название организации"
readOnly={!isEditing || !!(formData.orgName || user?.organization?.name)}
placeholder={user?.organization?.type === 'SELLER' ? 'Название магазина' : 'Название организации'}
readOnly={true}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
{user?.organization?.type === 'SELLER' ? (
<p className="text-white/50 text-xs mt-1">
Название устанавливается при регистрации кабинета и не может быть изменено.
</p>
) : (
<p className="text-white/50 text-xs mt-1">
Автоматически заполняется из федерального реестра при указании ИНН.
</p>
)}
</div>
<div>
@ -904,27 +957,29 @@ export function UserSettings() {
{/* Руководитель и статус */}
<div className="grid grid-cols-2 gap-4">
{user?.organization?.managementName && (
<div>
<Label className="text-white/80 text-sm mb-2 block">Руководитель</Label>
<Input
value={user.organization.managementName}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
)}
<div>
<Label className="text-white/80 text-sm mb-2 block">Руководитель организации</Label>
<Input
value={user?.organization?.managementName || 'Данные не указаны в реестре'}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
placeholder="Данные отсутствуют в федеральном реестре"
/>
<p className="text-white/50 text-xs mt-1">
{user?.organization?.managementName
? 'Данные из федерального реестра'
: 'Автоматически заполняется из федерального реестра при указании ИНН'}
</p>
</div>
{user?.organization?.status && (
<div>
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
<Input
value={user.organization.status === 'ACTIVE' ? 'Действующая' : user.organization.status}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
)}
<div>
<Label className="text-white/80 text-sm mb-2 block">Статус организации</Label>
<Input
value={user?.organization?.status === 'ACTIVE' ? 'Действующая' : user?.organization?.status || 'Статус не указан'}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
/>
</div>
</div>
{/* Дата регистрации */}
@ -950,7 +1005,7 @@ export function UserSettings() {
{/* Финансовые данные */}
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE') && (
{(user?.organization?.type === 'FULFILLMENT' || user?.organization?.type === 'LOGIST' || user?.organization?.type === 'WHOLESALE' || user?.organization?.type === 'SELLER') && (
<TabsContent value="financial" className="flex-1 overflow-hidden">
<Card className="glass-card p-6 h-full overflow-auto">
<div className="flex items-center gap-3 mb-6">
@ -965,7 +1020,7 @@ export function UserSettings() {
<div>
<Label className="text-white/80 text-sm mb-2 block">Название банка</Label>
<Input
value={formData.bankName}
value={formData.bankName || ''}
onChange={(e) => handleInputChange('bankName', e.target.value)}
placeholder="ПАО Сбербанк"
readOnly={!isEditing}
@ -977,7 +1032,7 @@ export function UserSettings() {
<div>
<Label className="text-white/80 text-sm mb-2 block">БИК</Label>
<Input
value={formData.bik}
value={formData.bik || ''}
onChange={(e) => handleInputChange('bik', e.target.value)}
placeholder="044525225"
readOnly={!isEditing}
@ -988,7 +1043,7 @@ export function UserSettings() {
<div>
<Label className="text-white/80 text-sm mb-2 block">Корр. счет</Label>
<Input
value={formData.corrAccount}
value={formData.corrAccount || ''}
onChange={(e) => handleInputChange('corrAccount', e.target.value)}
placeholder="30101810400000000225"
readOnly={!isEditing}
@ -1000,7 +1055,7 @@ export function UserSettings() {
<div>
<Label className="text-white/80 text-sm mb-2 block">Расчетный счет</Label>
<Input
value={formData.accountNumber}
value={formData.accountNumber || ''}
onChange={(e) => handleInputChange('accountNumber', e.target.value)}
placeholder="40702810123456789012"
readOnly={!isEditing}
@ -1028,14 +1083,16 @@ export function UserSettings() {
<div>
<Label className="text-white/80 text-sm mb-2 block">Wildberries API</Label>
<Input
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : ''}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
value={isEditing ? (formData.wildberriesApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') ? '••••••••••••••••••••' : '')}
onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)}
placeholder="Введите API ключ Wildberries"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
{user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') && (
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') || (formData.wildberriesApiKey && isEditing)) && (
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
API ключ настроен
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
</p>
)}
</div>
@ -1043,14 +1100,16 @@ export function UserSettings() {
<div>
<Label className="text-white/80 text-sm mb-2 block">Ozon API</Label>
<Input
value={user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : ''}
readOnly
className="glass-input text-white h-10 read-only:opacity-70"
value={isEditing ? (formData.ozonApiKey || '') : (user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') ? '••••••••••••••••••••' : '')}
onChange={(e) => handleInputChange('ozonApiKey', e.target.value)}
placeholder="Введите API ключ Ozon"
readOnly={!isEditing}
className="glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70"
/>
{user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') && (
{(user?.organization?.apiKeys?.find(key => key.marketplace === 'OZON') || (formData.ozonApiKey && isEditing)) && (
<p className="text-green-400 text-sm mt-2 flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
API ключ настроен
{!isEditing ? 'API ключ настроен' : 'Будет сохранен'}
</p>
)}
</div>

View File

@ -2,21 +2,18 @@
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Users,
Clock,
Send,
CheckCircle,
XCircle,
ArrowUpCircle,
ArrowDownCircle
} from 'lucide-react'
import { OrganizationCard } from './organization-card'
import { GET_MY_COUNTERPARTIES, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
import { OrganizationAvatar } from './organization-avatar'
import { GET_MY_COUNTERPARTIES, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS, SEARCH_ORGANIZATIONS } from '@/graphql/queries'
import { RESPOND_TO_COUNTERPARTY_REQUEST, CANCEL_COUNTERPARTY_REQUEST, REMOVE_COUNTERPARTY } from '@/graphql/mutations'
interface Organization {
@ -47,28 +44,43 @@ export function MarketCounterparties() {
const { data: outgoingData, loading: outgoingLoading, refetch: refetchOutgoing } = useQuery(GET_OUTGOING_REQUESTS)
const [respondToRequest] = useMutation(RESPOND_TO_COUNTERPARTY_REQUEST, {
onCompleted: () => {
refetchIncoming()
refetchCounterparties()
}
refetchQueries: [
{ query: GET_INCOMING_REQUESTS },
{ query: GET_MY_COUNTERPARTIES },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
],
awaitRefetchQueries: true
})
const [cancelRequest] = useMutation(CANCEL_COUNTERPARTY_REQUEST, {
onCompleted: () => {
refetchOutgoing()
}
refetchQueries: [
{ query: GET_OUTGOING_REQUESTS },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
],
awaitRefetchQueries: true
})
const [removeCounterparty] = useMutation(REMOVE_COUNTERPARTY, {
onCompleted: () => {
refetchCounterparties()
}
refetchQueries: [
{ query: GET_MY_COUNTERPARTIES },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } }
],
awaitRefetchQueries: true
})
const handleAcceptRequest = async (requestId: string) => {
try {
await respondToRequest({
variables: { requestId, response: 'ACCEPTED' }
variables: { requestId, accept: true }
})
} catch (error) {
console.error('Ошибка при принятии заявки:', error)
@ -78,7 +90,7 @@ export function MarketCounterparties() {
const handleRejectRequest = async (requestId: string) => {
try {
await respondToRequest({
variables: { requestId, response: 'REJECTED' }
variables: { requestId, accept: false }
})
} catch (error) {
console.error('Ошибка при отклонении заявки:', error)
@ -207,50 +219,72 @@ export function MarketCounterparties() {
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{incomingRequests.map((request: CounterpartyRequest) => (
<Card key={request.id} className="glass-card p-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
{(request.sender.name || request.sender.fullName || 'O').charAt(0).toUpperCase()}
</div>
</div>
<div key={request.id} className="glass-card p-4 w-full">
<div className="flex flex-col space-y-4">
<div className="flex items-start space-x-3">
<OrganizationAvatar organization={request.sender} size="md" />
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium">
{request.sender.name || request.sender.fullName}
</h4>
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
{request.message && (
<p className="text-white/80 text-sm mt-2 italic">&quot;{request.message}&quot;</p>
)}
<div className="flex items-center space-x-2 mt-2">
<Clock className="h-3 w-3 text-white/40" />
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
<div className="flex flex-col space-y-2 mb-3">
<h4 className="text-white font-medium text-lg leading-tight">
{request.sender.name || request.sender.fullName}
</h4>
<div className="flex items-center space-x-3">
<Badge className={
request.sender.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' :
request.sender.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
request.sender.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
request.sender.type === 'WHOLESALE' ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' :
'bg-gray-500/20 text-gray-300 border-gray-500/30'
}>
{request.sender.type === 'FULFILLMENT' ? 'Фулфилмент' :
request.sender.type === 'SELLER' ? 'Селлер' :
request.sender.type === 'LOGIST' ? 'Логистика' :
request.sender.type === 'WHOLESALE' ? 'Оптовик' :
request.sender.type}
</Badge>
</div>
</div>
<div className="space-y-2">
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
{request.sender.address && (
<div className="flex items-center text-white/60 text-sm">
<span className="truncate">{request.sender.address}</span>
</div>
)}
{request.message && (
<div className="p-2 bg-white/5 rounded border border-white/10">
<p className="text-white/80 text-sm italic">&quot;{request.message}&quot;</p>
</div>
)}
<div className="flex items-center text-white/40 text-xs">
<span>Заявка от {formatDate(request.createdAt)}</span>
</div>
</div>
</div>
</div>
<div className="flex space-x-2 ml-4">
<div className="flex space-x-2">
<Button
size="sm"
onClick={() => handleAcceptRequest(request.id)}
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30 cursor-pointer"
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30 cursor-pointer flex-1"
>
<CheckCircle className="h-4 w-4" />
Принять
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleRejectRequest(request.id)}
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer"
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer flex-1"
>
<XCircle className="h-4 w-4" />
Отклонить
</Button>
</div>
</div>
</Card>
</div>
))}
</div>
)}
@ -269,36 +303,56 @@ export function MarketCounterparties() {
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{outgoingRequests.map((request: CounterpartyRequest) => (
<Card key={request.id} className="glass-card p-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
{(request.receiver.name || request.receiver.fullName || 'O').charAt(0).toUpperCase()}
</div>
</div>
<div key={request.id} className="glass-card p-4 w-full">
<div className="flex flex-col space-y-4">
<div className="flex items-start space-x-3">
<OrganizationAvatar organization={request.receiver} size="md" />
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium">
{request.receiver.name || request.receiver.fullName}
</h4>
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
{request.message && (
<p className="text-white/80 text-sm mt-2 italic">&quot;{request.message}&quot;</p>
)}
<div className="flex items-center space-x-4 mt-2">
<div className="flex items-center space-x-2">
<Clock className="h-3 w-3 text-white/40" />
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
<div className="flex flex-col space-y-2 mb-3">
<h4 className="text-white font-medium text-lg leading-tight">
{request.receiver.name || request.receiver.fullName}
</h4>
<div className="flex items-center space-x-3">
<Badge className={
request.receiver.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' :
request.receiver.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
request.receiver.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
request.receiver.type === 'WHOLESALE' ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' :
'bg-gray-500/20 text-gray-300 border-gray-500/30'
}>
{request.receiver.type === 'FULFILLMENT' ? 'Фулфилмент' :
request.receiver.type === 'SELLER' ? 'Селлер' :
request.receiver.type === 'LOGIST' ? 'Логистика' :
request.receiver.type === 'WHOLESALE' ? 'Оптовик' :
request.receiver.type}
</Badge>
<Badge className={
request.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' :
request.status === 'REJECTED' ? 'bg-red-500/20 text-red-300 border-red-500/30' :
'bg-gray-500/20 text-gray-300 border-gray-500/30'
}>
{request.status === 'PENDING' ? 'Ожидает ответа' : request.status === 'REJECTED' ? 'Отклонено' : request.status}
</Badge>
</div>
</div>
<div className="space-y-2">
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
{request.receiver.address && (
<div className="flex items-center text-white/60 text-sm">
<span className="truncate">{request.receiver.address}</span>
</div>
)}
{request.message && (
<div className="p-2 bg-white/5 rounded border border-white/10">
<p className="text-white/80 text-sm italic">&quot;{request.message}&quot;</p>
</div>
)}
<div className="flex items-center text-white/40 text-xs">
<span>Отправлено {formatDate(request.createdAt)}</span>
</div>
<Badge className={
request.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' :
request.status === 'REJECTED' ? 'bg-red-500/20 text-red-300 border-red-500/30' :
'bg-gray-500/20 text-gray-300 border-gray-500/30'
}>
{request.status === 'PENDING' ? 'Ожидает' : request.status === 'REJECTED' ? 'Отклонено' : request.status}
</Badge>
</div>
</div>
</div>
@ -308,13 +362,13 @@ export function MarketCounterparties() {
size="sm"
variant="outline"
onClick={() => handleCancelRequest(request.id)}
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer ml-4"
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer w-full"
>
<XCircle className="h-4 w-4" />
Отменить заявку
</Button>
)}
</div>
</Card>
</div>
))}
</div>
)}

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Search, Package } from 'lucide-react'
import { OrganizationCard } from './organization-card'
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
interface Organization {
@ -21,6 +21,9 @@ interface Organization {
createdAt: string
users?: Array<{ id: string, avatar?: string }>
isCounterparty?: boolean
isCurrentUser?: boolean
hasOutgoingRequest?: boolean
hasIncomingRequest?: boolean
}
export function MarketFulfillment() {
@ -31,9 +34,15 @@ export function MarketFulfillment() {
})
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
onCompleted: () => {
refetch()
}
refetchQueries: [
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
{ query: GET_OUTGOING_REQUESTS },
{ query: GET_INCOMING_REQUESTS }
],
awaitRefetchQueries: true
})
const handleSearch = () => {
@ -44,7 +53,7 @@ export function MarketFulfillment() {
try {
await sendRequest({
variables: {
receiverId: organizationId,
organizationId: organizationId,
message: message || 'Заявка на добавление в контрагенты'
}
})

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Search, Truck } from 'lucide-react'
import { OrganizationCard } from './organization-card'
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
interface Organization {
@ -21,6 +21,9 @@ interface Organization {
createdAt: string
users?: Array<{ id: string, avatar?: string }>
isCounterparty?: boolean
isCurrentUser?: boolean
hasOutgoingRequest?: boolean
hasIncomingRequest?: boolean
}
export function MarketLogistics() {
@ -31,9 +34,15 @@ export function MarketLogistics() {
})
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
onCompleted: () => {
refetch()
}
refetchQueries: [
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
{ query: GET_OUTGOING_REQUESTS },
{ query: GET_INCOMING_REQUESTS }
],
awaitRefetchQueries: true
})
const handleSearch = () => {
@ -44,7 +53,7 @@ export function MarketLogistics() {
try {
await sendRequest({
variables: {
receiverId: organizationId,
organizationId: organizationId,
message: message || 'Заявка на добавление в контрагенты'
}
})

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Search, ShoppingCart } from 'lucide-react'
import { OrganizationCard } from './organization-card'
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
interface Organization {
@ -21,6 +21,9 @@ interface Organization {
createdAt: string
users?: Array<{ id: string, avatar?: string }>
isCounterparty?: boolean
isCurrentUser?: boolean
hasOutgoingRequest?: boolean
hasIncomingRequest?: boolean
}
export function MarketSellers() {
@ -31,9 +34,15 @@ export function MarketSellers() {
})
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
onCompleted: () => {
refetch()
}
refetchQueries: [
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
{ query: GET_OUTGOING_REQUESTS },
{ query: GET_INCOMING_REQUESTS }
],
awaitRefetchQueries: true
})
const handleSearch = () => {
@ -44,7 +53,7 @@ export function MarketSellers() {
try {
await sendRequest({
variables: {
receiverId: organizationId,
organizationId: organizationId,
message: message || 'Заявка на добавление в контрагенты'
}
})

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Search, Boxes } from 'lucide-react'
import { OrganizationCard } from './organization-card'
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
import { SEARCH_ORGANIZATIONS, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
interface Organization {
@ -21,6 +21,9 @@ interface Organization {
createdAt: string
users?: Array<{ id: string, avatar?: string }>
isCounterparty?: boolean
isCurrentUser?: boolean
hasOutgoingRequest?: boolean
hasIncomingRequest?: boolean
}
export function MarketWholesale() {
@ -31,9 +34,15 @@ export function MarketWholesale() {
})
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
onCompleted: () => {
refetch()
}
refetchQueries: [
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'SELLER' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'FULFILLMENT' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'LOGIST' } },
{ query: SEARCH_ORGANIZATIONS, variables: { type: 'WHOLESALE' } },
{ query: GET_OUTGOING_REQUESTS },
{ query: GET_INCOMING_REQUESTS }
],
awaitRefetchQueries: true
})
const handleSearch = () => {
@ -44,7 +53,7 @@ export function MarketWholesale() {
try {
await sendRequest({
variables: {
receiverId: organizationId,
organizationId: organizationId,
message: message || 'Заявка на добавление в контрагенты'
}
})

View File

@ -11,7 +11,8 @@ import {
Calendar,
Plus,
Send,
Trash2
Trash2,
User
} from 'lucide-react'
import { OrganizationAvatar } from './organization-avatar'
import { useState } from 'react'
@ -28,6 +29,9 @@ interface Organization {
createdAt: string
users?: Array<{ id: string, avatar?: string }>
isCounterparty?: boolean
isCurrentUser?: boolean
hasOutgoingRequest?: boolean
hasIncomingRequest?: boolean
}
interface OrganizationCardProps {
@ -144,7 +148,12 @@ export function OrganizationCard({
<Badge className={getTypeColor(organization.type)}>
{getTypeLabel(organization.type)}
</Badge>
{organization.isCounterparty && (
{organization.isCurrentUser && (
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30">
Это вы
</Badge>
)}
{organization.isCounterparty && !organization.isCurrentUser && (
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
Уже добавлен
</Badge>
@ -190,16 +199,29 @@ export function OrganizationCard({
<Trash2 className="h-4 w-4 mr-2" />
Удалить из контрагентов
</Button>
) : organization.isCurrentUser ? (
<Button
size="sm"
variant="outline"
disabled
className="bg-blue-500/10 text-blue-300 border-blue-500/30 w-full opacity-50"
>
<User className="h-4 w-4 mr-2" />
Ваша организация
</Button>
) : (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
size="sm"
disabled={organization.isCounterparty}
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty)} w-full cursor-pointer`}
disabled={organization.isCounterparty || organization.hasOutgoingRequest || organization.hasIncomingRequest}
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty || !!organization.hasOutgoingRequest || !!organization.hasIncomingRequest)} w-full cursor-pointer`}
>
<Plus className="h-4 w-4 mr-2" />
{organization.isCounterparty ? 'Уже добавлен' : actionButtonText}
{organization.isCounterparty ? 'Уже добавлен' :
organization.hasOutgoingRequest ? 'Заявка отправлена' :
organization.hasIncomingRequest ? 'Уже подал заявку' :
actionButtonText}
</Button>
</DialogTrigger>

View File

@ -0,0 +1,432 @@
"use client"
import { useState, useRef, useEffect } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { GET_MESSAGES } from '@/graphql/queries'
import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE } from '@/graphql/mutations'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { EmojiPickerComponent } from '@/components/ui/emoji-picker'
import { VoiceRecorder } from '@/components/ui/voice-recorder'
import { VoicePlayer } from '@/components/ui/voice-player'
import { FileUploader } from '@/components/ui/file-uploader'
import { ImageMessage } from '@/components/ui/image-message'
import { FileMessage } from '@/components/ui/file-message'
import { Send, MoreVertical } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
interface Organization {
id: string
inn: string
name?: string
fullName?: string
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
address?: string
users?: Array<{ id: string, avatar?: string, managerName?: string }>
}
interface Message {
id: string
content?: string
type?: 'TEXT' | 'VOICE' | 'IMAGE' | 'FILE' | null
voiceUrl?: string
voiceDuration?: number
fileUrl?: string
fileName?: string
fileSize?: number
fileType?: string
senderId: string
senderOrganization: Organization
receiverOrganization: Organization
createdAt: string
isRead: boolean
}
interface MessengerChatProps {
counterparty: Organization
}
export function MessengerChat({ counterparty }: MessengerChatProps) {
const { user } = useAuth()
const [message, setMessage] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
// Загружаем сообщения с контрагентом
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
variables: { counterpartyId: counterparty.id },
pollInterval: 3000, // Обновляем каждые 3 секунды для получения новых сообщений
fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные
errorPolicy: 'all' // Показываем данные даже при ошибках
})
const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
onCompleted: () => {
refetch() // Перезагружаем сообщения после отправки
},
onError: (error) => {
console.error('Ошибка отправки сообщения:', error)
}
})
const [sendVoiceMessageMutation] = useMutation(SEND_VOICE_MESSAGE, {
onCompleted: () => {
refetch() // Перезагружаем сообщения после отправки
},
onError: (error) => {
console.error('Ошибка отправки голосового сообщения:', error)
}
})
const [sendImageMessageMutation] = useMutation(SEND_IMAGE_MESSAGE, {
onCompleted: () => {
refetch()
},
onError: (error) => {
console.error('Ошибка отправки изображения:', error)
}
})
const [sendFileMessageMutation] = useMutation(SEND_FILE_MESSAGE, {
onCompleted: () => {
refetch()
},
onError: (error) => {
console.error('Ошибка отправки файла:', error)
}
})
const messages = messagesData?.messages || []
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const getOrganizationName = (org: Organization) => {
return org.name || org.fullName || 'Организация'
}
const getManagerName = (org: Organization) => {
return org.users?.[0]?.managerName || 'Управляющий'
}
const getInitials = (org: Organization) => {
const name = getOrganizationName(org)
return name.charAt(0).toUpperCase()
}
const getTypeLabel = (type: string) => {
switch (type) {
case 'FULFILLMENT':
return 'Фулфилмент'
case 'SELLER':
return 'Селлер'
case 'LOGIST':
return 'Логистика'
case 'WHOLESALE':
return 'Оптовик'
default:
return type
}
}
const getTypeColor = (type: string) => {
switch (type) {
case 'FULFILLMENT':
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
case 'SELLER':
return 'bg-green-500/20 text-green-300 border-green-500/30'
case 'LOGIST':
return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
case 'WHOLESALE':
return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
default:
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
}
}
const handleSendMessage = async () => {
if (!message.trim()) return
try {
await sendMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
content: message.trim()
}
})
setMessage('')
} catch (error) {
console.error('Ошибка отправки сообщения:', error)
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
const handleEmojiSelect = (emoji: string) => {
setMessage(prev => prev + emoji)
}
const handleSendVoice = async (audioUrl: string, duration: number) => {
try {
await sendVoiceMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
voiceUrl: audioUrl,
voiceDuration: duration
}
})
} catch (error) {
console.error('Ошибка отправки голосового сообщения:', error)
}
}
const handleSendFile = async (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => {
try {
if (messageType === 'IMAGE') {
await sendImageMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
fileUrl,
fileName,
fileSize,
fileType
}
})
} else {
await sendFileMessageMutation({
variables: {
receiverOrganizationId: counterparty.id,
fileUrl,
fileName,
fileSize,
fileType
}
})
}
} catch (error) {
console.error('Ошибка отправки файла:', error)
}
}
const formatTime = (dateString: string) => {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return '00:00'
}
return date.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return 'Неизвестная дата'
}
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === today.toDateString()) {
return 'Сегодня'
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Вчера'
} else {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long'
})
}
}
return (
<div className="flex flex-col h-full">
{/* Заголовок чата */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center space-x-3">
<Avatar className="h-10 w-10">
{counterparty.users?.[0]?.avatar ? (
<AvatarImage
src={counterparty.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials(counterparty)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-white font-medium">
{getOrganizationName(counterparty)}
</h3>
<p className="text-white/60 text-sm mb-1">
{getManagerName(counterparty)}
</p>
<div className="flex items-center space-x-2">
<Badge className={`${getTypeColor(counterparty.type)} text-xs`}>
{getTypeLabel(counterparty.type)}
</Badge>
</div>
</div>
</div>
<Button variant="ghost" size="sm" className="text-white/60 hover:text-white">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
{/* Область сообщений */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-white/60">Загрузка сообщений...</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Send className="h-6 w-6 text-white/40" />
</div>
<p className="text-white/60 text-sm mb-1">Начните беседу</p>
<p className="text-white/40 text-xs">
Отправьте первое сообщение {getOrganizationName(counterparty)}
</p>
</div>
</div>
) : (
messages.map((msg: Message, index: number) => {
const isCurrentUser = msg.senderOrganization?.id === user?.organization?.id
const showDate = index === 0 ||
formatDate(messages[index - 1].createdAt) !== formatDate(msg.createdAt)
return (
<div key={msg.id}>
{showDate && (
<div className="flex justify-center mb-4">
<div className="bg-white/10 rounded-full px-3 py-1">
<span className="text-white/60 text-xs">
{formatDate(msg.createdAt)}
</span>
</div>
</div>
)}
<div className={`flex ${isCurrentUser ? 'justify-end' : 'justify-start'} mb-3`}>
<div className="flex flex-col max-w-xs lg:max-w-md">
{/* Имя отправителя */}
{!isCurrentUser && (
<div className="flex items-center space-x-2 mb-1 px-1">
<Avatar className="h-6 w-6">
{msg.senderOrganization?.users?.[0]?.avatar ? (
<AvatarImage
src={msg.senderOrganization.users[0].avatar}
alt="Аватар отправителя"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-xs">
{getInitials(msg.senderOrganization)}
</AvatarFallback>
</Avatar>
<span className="text-white/60 text-xs">
{getManagerName(msg.senderOrganization)}
</span>
<Badge className={`${getTypeColor(msg.senderOrganization.type)} text-xs`}>
{getTypeLabel(msg.senderOrganization.type)}
</Badge>
</div>
)}
{/* Сообщение */}
<div className="space-y-2">
{msg.type === 'VOICE' && msg.voiceUrl ? (
<VoicePlayer
audioUrl={msg.voiceUrl}
duration={msg.voiceDuration || 0}
isCurrentUser={isCurrentUser}
/>
) : msg.type === 'IMAGE' && msg.fileUrl ? (
<ImageMessage
imageUrl={msg.fileUrl}
fileName={msg.fileName || 'image'}
fileSize={msg.fileSize}
isCurrentUser={isCurrentUser}
/>
) : msg.type === 'FILE' && msg.fileUrl ? (
<FileMessage
fileUrl={msg.fileUrl}
fileName={msg.fileName || 'file'}
fileSize={msg.fileSize}
fileType={msg.fileType}
isCurrentUser={isCurrentUser}
/>
) : msg.content ? (
<div className={`px-4 py-2 rounded-lg ${
isCurrentUser
? 'bg-blue-500/20 text-white border border-blue-500/30'
: 'bg-white/10 text-white border border-white/20'
}`}>
<p className="text-sm leading-relaxed">{msg.content}</p>
</div>
) : null}
<p className={`text-xs ${
isCurrentUser ? 'text-blue-300/70' : 'text-white/50'
}`}>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
</div>
</div>
)
})
)}
<div ref={messagesEndRef} />
</div>
{/* Поле ввода сообщения */}
<div className="p-4 border-t border-white/10">
<div className="flex space-x-2">
<div className="flex flex-1 space-x-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Введите сообщение..."
className="glass-input text-white placeholder:text-white/40 flex-1"
/>
<EmojiPickerComponent onEmojiSelect={handleEmojiSelect} />
<FileUploader onSendFile={handleSendFile} />
<VoiceRecorder onSendVoice={handleSendVoice} />
</div>
<Button
onClick={handleSendMessage}
disabled={!message.trim()}
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer"
variant="outline"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,178 @@
"use client"
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Users, Search } from 'lucide-react'
import { useState } from 'react'
interface Organization {
id: string
inn: string
name?: string
fullName?: string
managementName?: string
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
address?: string
phones?: Array<{ value: string }>
emails?: Array<{ value: string }>
users?: Array<{ id: string, avatar?: string, managerName?: string }>
createdAt: string
}
interface MessengerConversationsProps {
counterparties: Organization[]
loading: boolean
selectedCounterparty: string | null
onSelectCounterparty: (counterpartyId: string) => void
}
export function MessengerConversations({
counterparties,
loading,
selectedCounterparty,
onSelectCounterparty
}: MessengerConversationsProps) {
const [searchTerm, setSearchTerm] = useState('')
const getOrganizationName = (org: Organization) => {
return org.name || org.fullName || 'Организация'
}
const getManagerName = (org: Organization) => {
return org.users?.[0]?.managerName || 'Управляющий'
}
const getInitials = (org: Organization) => {
const name = getOrganizationName(org)
return name.charAt(0).toUpperCase()
}
const getTypeLabel = (type: string) => {
switch (type) {
case 'FULFILLMENT':
return 'Фулфилмент'
case 'SELLER':
return 'Селлер'
case 'LOGIST':
return 'Логистика'
case 'WHOLESALE':
return 'Оптовик'
default:
return type
}
}
const getTypeColor = (type: string) => {
switch (type) {
case 'FULFILLMENT':
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
case 'SELLER':
return 'bg-green-500/20 text-green-300 border-green-500/30'
case 'LOGIST':
return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
case 'WHOLESALE':
return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
default:
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
}
}
const filteredCounterparties = counterparties.filter(org => {
if (!searchTerm) return true
const name = getOrganizationName(org).toLowerCase()
const managerName = getManagerName(org).toLowerCase()
const inn = org.inn.toLowerCase()
const search = searchTerm.toLowerCase()
return name.includes(search) || inn.includes(search) || managerName.includes(search)
})
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-white/60">Загрузка...</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Заголовок */}
<div className="flex items-center space-x-3 mb-4">
<Users className="h-5 w-5 text-blue-400" />
<div>
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
<p className="text-white/60 text-sm">{counterparties.length} активных</p>
</div>
</div>
{/* Поиск */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
<Input
placeholder="Поиск по названию или ИНН..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="glass-input text-white placeholder:text-white/40 pl-10 h-10"
/>
</div>
{/* Список контрагентов */}
<div className="flex-1 overflow-auto space-y-2">
{filteredCounterparties.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="h-6 w-6 text-white/40" />
</div>
<p className="text-white/60 text-sm">
{searchTerm ? 'Ничего не найдено' : 'Контрагенты не найдены'}
</p>
</div>
) : (
filteredCounterparties.map((org) => (
<div
key={org.id}
onClick={() => onSelectCounterparty(org.id)}
className={`p-3 rounded-lg cursor-pointer transition-all duration-200 ${
selectedCounterparty === org.id
? 'bg-white/20 border border-white/30'
: 'bg-white/5 hover:bg-white/10 border border-white/10'
}`}
>
<div className="flex items-start space-x-3">
<Avatar className="h-10 w-10 flex-shrink-0">
{org.users?.[0]?.avatar ? (
<AvatarImage
src={org.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials(org)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium text-sm leading-tight truncate mb-1">
{getOrganizationName(org)}
</h4>
<div className="flex items-center space-x-2 mb-2">
<Badge className={`${getTypeColor(org.type)} text-xs`}>
{getTypeLabel(org.type)}
</Badge>
</div>
<p className="text-white/60 text-xs truncate">
{getManagerName(org)}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
)
}

View File

@ -0,0 +1,113 @@
"use client"
import { useState } from 'react'
import { useQuery } from '@apollo/client'
import { Card } from '@/components/ui/card'
import { Sidebar } from '@/components/dashboard/sidebar'
import { MessengerConversations } from './messenger-conversations'
import { MessengerChat } from './messenger-chat'
import { MessengerEmptyState } from './messenger-empty-state'
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
import { MessageCircle } from 'lucide-react'
interface Organization {
id: string
inn: string
name?: string
fullName?: string
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
address?: string
phones?: Array<{ value: string }>
emails?: Array<{ value: string }>
users?: Array<{ id: string, avatar?: string }>
createdAt: string
}
export function MessengerDashboard() {
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
const counterparties = counterpartiesData?.myCounterparties || []
const handleSelectCounterparty = (counterpartyId: string) => {
setSelectedCounterparty(counterpartyId)
}
const selectedCounterpartyData = counterparties.find((cp: Organization) => cp.id === selectedCounterparty)
// Если нет контрагентов, показываем заглушку
if (!counterpartiesLoading && counterparties.length === 0) {
return (
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
<Sidebar />
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
<p className="text-white/70 text-sm">Общение с контрагентами</p>
</div>
</div>
<div className="flex-1 overflow-hidden">
<Card className="glass-card h-full overflow-hidden p-6">
<MessengerEmptyState />
</Card>
</div>
</div>
</main>
</div>
)
}
return (
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
<Sidebar />
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
<div className="h-full w-full flex flex-col">
{/* Заголовок - фиксированная высота */}
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
<p className="text-white/70 text-sm">Общение с контрагентами</p>
</div>
</div>
{/* Основной контент - сетка из 2 колонок */}
<div className="flex-1 overflow-hidden">
<div className="grid grid-cols-[350px_1fr] gap-4 h-full">
{/* Левая колонка - список контрагентов */}
<Card className="glass-card h-full overflow-hidden p-4">
<MessengerConversations
counterparties={counterparties}
loading={counterpartiesLoading}
selectedCounterparty={selectedCounterparty}
onSelectCounterparty={handleSelectCounterparty}
/>
</Card>
{/* Правая колонка - чат */}
<Card className="glass-card h-full overflow-hidden">
{selectedCounterparty && selectedCounterpartyData ? (
<MessengerChat counterparty={selectedCounterpartyData} />
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<MessageCircle className="h-8 w-8 text-white/40" />
</div>
<p className="text-white/60 text-lg mb-2">Выберите контрагента</p>
<p className="text-white/40 text-sm">
Начните беседу с одним из ваших контрагентов
</p>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
</main>
</div>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import { Button } from '@/components/ui/button'
import { MessageCircle, Store, Users } from 'lucide-react'
import { useRouter } from 'next/navigation'
export function MessengerEmptyState() {
const router = useRouter()
const handleGoToMarket = () => {
router.push('/market')
}
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-md">
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
<MessageCircle className="h-10 w-10 text-white/40" />
</div>
<h3 className="text-xl font-semibold text-white mb-3">
У вас пока нет контрагентов
</h3>
<p className="text-white/60 text-sm mb-6 leading-relaxed">
Чтобы начать общение, сначала найдите и добавьте контрагентов в разделе «Маркет».
После добавления они появятся здесь для общения.
</p>
<div className="space-y-3">
<Button
onClick={handleGoToMarket}
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 w-full cursor-pointer"
variant="outline"
>
<Store className="h-4 w-4 mr-2" />
Перейти в Маркет
</Button>
<div className="flex items-center justify-center text-white/40 text-xs">
<Users className="h-3 w-3 mr-1" />
Найдите партнеров и начните общение
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,68 @@
"use client"
import dynamic from 'next/dynamic'
import { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Smile } from 'lucide-react'
// Динамически импортируем EmojiPicker чтобы избежать проблем с SSR
const EmojiPicker = dynamic(
() => import('emoji-picker-react'),
{ ssr: false }
)
interface EmojiPickerComponentProps {
onEmojiSelect: (emoji: string) => void
}
export function EmojiPickerComponent({ onEmojiSelect }: EmojiPickerComponentProps) {
const [showPicker, setShowPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setShowPicker(false)
}
}
if (showPicker) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showPicker])
const handleEmojiClick = (emojiData: { emoji: string }) => {
onEmojiSelect(emojiData.emoji)
setShowPicker(false)
}
return (
<div className="relative" ref={pickerRef}>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPicker(!showPicker)}
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
>
<Smile className="h-4 w-4" />
</Button>
{showPicker && (
<div className="absolute bottom-full right-0 mb-2 z-50">
<div className="bg-gray-800 border border-white/20 rounded-lg overflow-hidden shadow-xl">
<EmojiPicker
onEmojiClick={handleEmojiClick}
width={350}
height={400}
/>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,108 @@
"use client"
import { Download, FileText, Archive, Image as ImageIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface FileMessageProps {
fileUrl: string
fileName: string
fileSize?: number
fileType?: string
isCurrentUser?: boolean
}
export function FileMessage({ fileUrl, fileName, fileSize, fileType, isCurrentUser = false }: FileMessageProps) {
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const getFileIcon = (type?: string) => {
if (!type) return <FileText className="h-6 w-6" />
if (type.includes('pdf')) return <FileText className="h-6 w-6 text-red-400" />
if (type.includes('zip') || type.includes('archive')) return <Archive className="h-6 w-6 text-yellow-400" />
if (type.includes('image')) return <ImageIcon className="h-6 w-6 text-green-400" />
if (type.includes('word') || type.includes('document')) return <FileText className="h-6 w-6 text-blue-400" />
if (type.includes('excel') || type.includes('spreadsheet')) return <FileText className="h-6 w-6 text-green-600" />
if (type.includes('powerpoint') || type.includes('presentation')) return <FileText className="h-6 w-6 text-orange-400" />
return <FileText className="h-6 w-6" />
}
const getFileExtension = (name: string) => {
const parts = name.split('.')
return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE'
}
const handleDownload = () => {
const link = document.createElement('a')
link.href = fileUrl
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
return (
<div className={`flex items-center space-x-3 p-3 rounded-lg max-w-sm cursor-pointer hover:opacity-80 transition-opacity ${
isCurrentUser
? 'bg-blue-500/20 border border-blue-500/30'
: 'bg-white/10 border border-white/20'
}`} onClick={handleDownload}>
{/* Иконка файла */}
<div className={`flex-shrink-0 w-12 h-12 rounded-lg flex items-center justify-center ${
isCurrentUser ? 'bg-blue-500/30' : 'bg-white/20'
}`}>
{getFileIcon(fileType)}
</div>
{/* Информация о файле */}
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm truncate ${
isCurrentUser ? 'text-white' : 'text-white'
}`}>
{fileName}
</p>
<div className="flex items-center space-x-2 mt-1">
{fileSize && (
<span className={`text-xs ${
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
}`}>
{formatFileSize(fileSize)}
</span>
)}
<span className={`text-xs ${
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
}`}>
{getFileExtension(fileName)}
</span>
</div>
</div>
{/* Кнопка скачивания */}
<Button
onClick={(e) => {
e.stopPropagation()
handleDownload()
}}
size="sm"
variant="ghost"
className={`flex-shrink-0 ${
isCurrentUser
? 'text-blue-300 hover:text-blue-200 hover:bg-blue-500/30'
: 'text-white/60 hover:text-white hover:bg-white/20'
}`}
>
<Download className="h-4 w-4" />
</Button>
</div>
)
}

View File

@ -0,0 +1,216 @@
"use client"
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Paperclip, Image, X } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
interface FileUploaderProps {
onSendFile: (fileUrl: string, fileName: string, fileSize: number, fileType: string, messageType: 'IMAGE' | 'FILE') => void
}
interface UploadedFile {
url: string
name: string
size: number
type: string
messageType: 'IMAGE' | 'FILE'
}
export function FileUploader({ onSendFile }: FileUploaderProps) {
const { user } = useAuth()
const [isUploading, setIsUploading] = useState(false)
const [selectedFile, setSelectedFile] = useState<UploadedFile | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const isImageType = (type: string) => {
return type.startsWith('image/')
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const handleFileSelect = async (file: File, messageType: 'IMAGE' | 'FILE') => {
if (!user?.id) return
setIsUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('userId', user.id)
formData.append('messageType', messageType)
const response = await fetch('/api/upload-file', {
method: 'POST',
body: formData
})
if (!response.ok) {
let errorMessage = 'Failed to upload file'
try {
const errorData = await response.json()
errorMessage = errorData.error || errorMessage
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`
}
throw new Error(errorMessage)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Upload failed')
}
setSelectedFile({
url: result.url,
name: result.originalName,
size: result.size,
type: result.type,
messageType: result.messageType
})
} catch (error) {
console.error('Error uploading file:', error)
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка'
alert(`Ошибка при загрузке файла: ${errorMessage}`)
} finally {
setIsUploading(false)
}
}
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>, messageType: 'IMAGE' | 'FILE') => {
const file = event.target.files?.[0]
if (file) {
handleFileSelect(file, messageType)
}
// Очищаем input для возможности выбора того же файла
event.target.value = ''
}
const handleSendFile = () => {
if (selectedFile) {
onSendFile(
selectedFile.url,
selectedFile.name,
selectedFile.size,
selectedFile.type,
selectedFile.messageType
)
setSelectedFile(null)
}
}
const handleCancelFile = () => {
setSelectedFile(null)
}
if (selectedFile) {
return (
<div className="flex items-center space-x-2 bg-white/10 rounded-lg p-3 border border-white/20">
<div className="flex items-center space-x-2 flex-1">
{isImageType(selectedFile.type) ? (
<div className="flex items-center space-x-2">
<img
src={selectedFile.url}
alt="Preview"
className="w-10 h-10 object-cover rounded"
/>
<div>
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
<p className="text-white/60 text-xs">{formatFileSize(selectedFile.size)}</p>
</div>
</div>
) : (
<div className="flex items-center space-x-2">
<div className="w-10 h-10 bg-blue-500/20 rounded flex items-center justify-center">
<Paperclip className="h-5 w-5 text-blue-300" />
</div>
<div>
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
<p className="text-white/60 text-xs">{formatFileSize(selectedFile.size)}</p>
</div>
</div>
)}
</div>
<div className="flex items-center space-x-1">
<Button
onClick={handleSendFile}
size="sm"
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30"
variant="outline"
>
Отправить
</Button>
<Button
onClick={handleCancelFile}
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)
}
return (
<div className="flex items-center space-x-2">
{/* Кнопка для загрузки изображений */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => imageInputRef.current?.click()}
disabled={isUploading}
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
>
<Image className="h-4 w-4" />
</Button>
{/* Кнопка для загрузки файлов */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
>
<Paperclip className="h-4 w-4" />
</Button>
{/* Скрытые input элементы */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFileInputChange(e, 'IMAGE')}
className="hidden"
/>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.json"
onChange={(e) => handleFileInputChange(e, 'FILE')}
className="hidden"
/>
{isUploading && (
<div className="text-white/60 text-xs">
Загрузка...
</div>
)}
</div>
)
}

View File

@ -0,0 +1,129 @@
"use client"
import { useState } from 'react'
import { Download, Eye } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ImageMessageProps {
imageUrl: string
fileName: string
fileSize?: number
isCurrentUser?: boolean
}
export function ImageMessage({ imageUrl, fileName, fileSize, isCurrentUser = false }: ImageMessageProps) {
const [isLoading, setIsLoading] = useState(true)
const [showFullSize, setShowFullSize] = useState(false)
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const handleDownload = () => {
const link = document.createElement('a')
link.href = imageUrl
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const handleImageClick = () => {
setShowFullSize(true)
}
return (
<>
<div className={`relative group max-w-xs ${
isCurrentUser
? 'bg-blue-500/20 border border-blue-500/30'
: 'bg-white/10 border border-white/20'
} rounded-lg overflow-hidden`}>
<div className="relative">
<img
src={imageUrl}
alt={fileName}
className="w-full h-auto cursor-pointer transition-opacity duration-200"
style={{
opacity: isLoading ? 0 : 1,
maxHeight: '300px',
objectFit: 'cover'
}}
onLoad={() => setIsLoading(false)}
onClick={handleImageClick}
/>
{isLoading && (
<div className="absolute inset-0 bg-gray-700 animate-pulse flex items-center justify-center">
<div className="text-white/60 text-sm">Загрузка...</div>
</div>
)}
{/* Overlay с кнопками при наведении */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center space-x-2">
<Button
onClick={handleImageClick}
size="sm"
variant="ghost"
className="text-white hover:bg-white/20"
>
<Eye className="h-4 w-4" />
</Button>
<Button
onClick={handleDownload}
size="sm"
variant="ghost"
className="text-white hover:bg-white/20"
>
<Download className="h-4 w-4" />
</Button>
</div>
</div>
{/* Информация о файле */}
<div className="p-2">
<p className={`text-xs ${
isCurrentUser ? 'text-blue-300/70' : 'text-white/60'
} truncate`}>
{fileName}
</p>
{fileSize && (
<p className={`text-xs ${
isCurrentUser ? 'text-blue-300/50' : 'text-white/40'
}`}>
{formatFileSize(fileSize)}
</p>
)}
</div>
</div>
{/* Полноэкранный просмотр */}
{showFullSize && (
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
onClick={() => setShowFullSize(false)}
>
<div className="relative max-w-full max-h-full">
<img
src={imageUrl}
alt={fileName}
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
<Button
onClick={() => setShowFullSize(false)}
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white"
size="sm"
>
×
</Button>
</div>
</div>
)}
</>
)
}

View File

@ -0,0 +1,174 @@
"use client"
import { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Play, Pause, Volume2 } from 'lucide-react'
interface VoicePlayerProps {
audioUrl: string
duration?: number
isCurrentUser?: boolean
}
export function VoicePlayer({ audioUrl, duration = 0, isCurrentUser = false }: VoicePlayerProps) {
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [audioDuration, setAudioDuration] = useState(duration > 0 ? duration : 0)
const [isLoading, setIsLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
// Обновляем длительность при изменении props
useEffect(() => {
if (duration > 0 && (!audioDuration || audioDuration === 0)) {
setAudioDuration(duration)
}
}, [duration, audioDuration])
useEffect(() => {
// Создаем аудио элемент
audioRef.current = new Audio(audioUrl)
const audio = audioRef.current
const handleLoadedMetadata = () => {
if (audio.duration && isFinite(audio.duration) && !isNaN(audio.duration)) {
setAudioDuration(audio.duration)
} else {
setAudioDuration(duration || 0)
}
setIsLoading(false)
}
const handleTimeUpdate = () => {
if (audio.currentTime && isFinite(audio.currentTime) && !isNaN(audio.currentTime)) {
setCurrentTime(audio.currentTime)
}
}
const handleEnded = () => {
setIsPlaying(false)
setCurrentTime(0)
}
const handleCanPlay = () => {
setIsLoading(false)
}
const handleLoadStart = () => {
setIsLoading(true)
}
const handleError = () => {
console.error('Audio loading error')
setIsLoading(false)
setIsPlaying(false)
}
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
audio.addEventListener('timeupdate', handleTimeUpdate)
audio.addEventListener('ended', handleEnded)
audio.addEventListener('canplay', handleCanPlay)
audio.addEventListener('loadstart', handleLoadStart)
audio.addEventListener('error', handleError)
return () => {
if (audio) {
audio.removeEventListener('loadedmetadata', handleLoadedMetadata)
audio.removeEventListener('timeupdate', handleTimeUpdate)
audio.removeEventListener('ended', handleEnded)
audio.removeEventListener('canplay', handleCanPlay)
audio.removeEventListener('loadstart', handleLoadStart)
audio.removeEventListener('error', handleError)
audio.pause()
}
}
}, [audioUrl])
const togglePlayPause = () => {
const audio = audioRef.current
if (!audio) return
if (isPlaying) {
audio.pause()
setIsPlaying(false)
} else {
audio.play().then(() => {
setIsPlaying(true)
}).catch((error) => {
console.error('Error playing audio:', error)
setIsPlaying(false)
})
}
}
const formatTime = (time: number) => {
if (isNaN(time) || !isFinite(time) || time < 0) return '0:00'
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const getProgress = () => {
if (!audioDuration || audioDuration === 0 || isNaN(audioDuration) || !isFinite(audioDuration)) return 0
if (!currentTime || isNaN(currentTime) || !isFinite(currentTime)) return 0
return Math.min((currentTime / audioDuration) * 100, 100)
}
return (
<div className={`flex items-center space-x-4 p-3 rounded-lg min-w-[200px] max-w-sm ${
isCurrentUser
? 'bg-blue-500/20 border border-blue-500/30'
: 'bg-white/10 border border-white/20'
}`}>
{/* Кнопка воспроизведения */}
<Button
onClick={togglePlayPause}
disabled={isLoading}
variant="ghost"
size="sm"
className={`p-2 rounded-full ${
isCurrentUser
? 'text-blue-300 hover:text-blue-200 hover:bg-blue-500/30'
: 'text-white hover:text-white/80 hover:bg-white/20'
}`}
>
{isLoading ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
{/* Визуализация волны / прогресс бар */}
<div className="flex-1 space-y-2">
<div className="flex items-center space-x-3">
<Volume2 className={`h-3 w-3 ${
isCurrentUser ? 'text-blue-300' : 'text-white/60'
}`} />
<div className="flex-1 h-1 bg-white/20 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
isCurrentUser ? 'bg-blue-400' : 'bg-white/60'
}`}
style={{ width: `${getProgress()}%` }}
/>
</div>
</div>
{/* Время */}
<div className="flex justify-between items-center text-xs">
<span className={`${isCurrentUser ? 'text-blue-300/70' : 'text-white/50'} min-w-[2rem]`}>
{formatTime(currentTime)}
</span>
<span className={`${isCurrentUser ? 'text-blue-300/70' : 'text-white/50'} min-w-[2rem] text-right`}>
{formatTime(audioDuration)}
</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,254 @@
"use client"
import { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Mic, MicOff, Square, Send, Trash2 } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
interface VoiceRecorderProps {
onSendVoice: (audioUrl: string, duration: number) => void
}
export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
const { user } = useAuth()
const [isRecording, setIsRecording] = useState(false)
const [recordedAudio, setRecordedAudio] = useState<string | null>(null)
const [duration, setDuration] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [permission, setPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt')
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const audioRef = useRef<HTMLAudioElement | null>(null)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// Проверяем доступность микрофона
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
navigator.permissions.query({ name: 'microphone' as PermissionName }).then((result) => {
setPermission(result.state as 'granted' | 'denied' | 'prompt')
}).catch(() => {
// Если permissions API недоступен, оставляем prompt
})
}
}, [])
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
setPermission('granted')
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
mediaRecorderRef.current = mediaRecorder
audioChunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
const audioUrl = URL.createObjectURL(audioBlob)
setRecordedAudio(audioUrl)
// Останавливаем все треки для освобождения микрофона
stream.getTracks().forEach(track => track.stop())
// Останавливаем таймер
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
mediaRecorder.start()
setIsRecording(true)
setDuration(0)
// Запускаем таймер записи
intervalRef.current = setInterval(() => {
setDuration(prev => prev + 1)
}, 1000)
} catch (error) {
console.error('Error accessing microphone:', error)
setPermission('denied')
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop()
setIsRecording(false)
}
}
const cancelRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop()
setIsRecording(false)
}
setRecordedAudio(null)
setDuration(0)
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
const playRecording = () => {
if (recordedAudio) {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.currentTime = 0
}
audioRef.current = new Audio(recordedAudio)
audioRef.current.play()
setIsPlaying(true)
audioRef.current.onended = () => {
setIsPlaying(false)
}
}
}
const sendVoiceMessage = async () => {
if (!recordedAudio || !user?.id) return
try {
// Конвертируем Blob в File для загрузки
const response = await fetch(recordedAudio)
const blob = await response.blob()
const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' })
// Загружаем в S3
const formData = new FormData()
formData.append('file', file)
formData.append('userId', user.id)
const uploadResponse = await fetch('/api/upload-voice', {
method: 'POST',
body: formData
})
if (!uploadResponse.ok) {
throw new Error('Failed to upload voice message')
}
const result = await uploadResponse.json()
// Отправляем голосовое сообщение
onSendVoice(result.url, duration)
// Очищаем состояние
setRecordedAudio(null)
setDuration(0)
} catch (error) {
console.error('Error sending voice message:', error)
}
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
if (permission === 'denied') {
return (
<div className="text-white/60 text-xs text-center p-2">
Доступ к микрофону запрещен
</div>
)
}
return (
<div className="flex items-center space-x-2">
{!recordedAudio ? (
// Состояние записи
<>
{!isRecording ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={startRecording}
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
>
<Mic className="h-4 w-4" />
</Button>
) : (
<>
<div className="flex items-center space-x-2 bg-red-500/20 rounded-lg px-3 py-2">
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
<span className="text-white text-sm font-mono">
{formatDuration(duration)}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={stopRecording}
className="text-white/80 hover:text-white p-1"
>
<Square className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelRecording}
className="text-red-400 hover:text-red-300 p-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</>
)}
</>
) : (
// Состояние воспроизведения и отправки
<div className="flex items-center space-x-2 bg-blue-500/20 rounded-lg px-3 py-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={playRecording}
className="text-blue-300 hover:text-blue-200 p-1"
>
{isPlaying ? <MicOff className="h-3 w-3" /> : <Mic className="h-3 w-3" />}
</Button>
<span className="text-white text-sm font-mono">
{formatDuration(duration)}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={sendVoiceMessage}
className="text-green-400 hover:text-green-300 p-1"
>
<Send className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setRecordedAudio(null)}
className="text-red-400 hover:text-red-300 p-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
)
}