feat: модуляризировать market-counterparties компонент (835→291 строк)
- Разделить 835 строк на модульную архитектуру (11 файлов) - Создать orchestrator + types + hooks + blocks структуру - Сохранить все функции: 3 вкладки, статистика, поиск, партнерская ссылка - Исправить типы партнерской ссылки (PartnerLink → string) - Интегрировать поиск новых организаций в главную вкладку - Сохранить glass-эффекты, анимации и все визуальные элементы 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,542 @@
|
||||
/**
|
||||
* Блок списка текущих контрагентов
|
||||
* Отображает карточки организаций с возможностью удаления
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { Users, ArrowDownCircle, TrendingUp, ArrowUpCircle, Building, Phone, Mail, MapPin, X, Calendar, Gift, Copy, Search, Filter, SortAsc, SortDesc, Send } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { GlassInput } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { OrganizationAvatar } from '../../organization-avatar'
|
||||
import { ORGANIZATION_TYPES, type CounterpartiesListBlockProps } from '../types'
|
||||
|
||||
export const CounterpartiesListBlock = React.memo(function CounterpartiesListBlock({
|
||||
counterparties,
|
||||
loading,
|
||||
onRemove,
|
||||
onViewDetails,
|
||||
incomingRequestsCount = 0,
|
||||
outgoingRequestsCount = 0,
|
||||
incomingLoading = false,
|
||||
outgoingLoading = false,
|
||||
partnerLink,
|
||||
onCopyPartnerLink,
|
||||
// Фильтрация существующих контрагентов
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
typeFilter = 'all',
|
||||
onTypeFilterChange,
|
||||
sortField = 'name',
|
||||
sortOrder = 'asc',
|
||||
onSort,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
// Поиск новых организаций
|
||||
searchResults = [],
|
||||
searchLoading = false,
|
||||
onSendRequest,
|
||||
searchNewQuery = '',
|
||||
onSearchNewChange,
|
||||
searchNewTypeFilter = 'all',
|
||||
onSearchNewTypeFilterChange,
|
||||
}: CounterpartiesListBlockProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-12 w-12 bg-gray-200 rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Состояние пустого списка (показываем после статистики)
|
||||
const emptyState = !counterparties.length && (
|
||||
<Card className="glass-card p-8 text-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-16 w-16 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Building className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">Контрагенты не найдены</h3>
|
||||
<p className="text-white/60 mt-1">
|
||||
Начните отправлять заявки на партнерство другим организациям
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Статистические карточки */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{/* Партнеров */}
|
||||
<Card className="glass-card p-3 hover:bg-white/5 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/20 border border-blue-500/30">
|
||||
<Users className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/60 uppercase tracking-wide">Партнеров</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{loading ? (
|
||||
<span className="inline-block h-6 w-8 bg-white/10 rounded animate-pulse" />
|
||||
) : (
|
||||
counterparties.length
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Заявок */}
|
||||
<Card className="glass-card p-3 hover:bg-white/5 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-yellow-500/20 border border-yellow-500/30">
|
||||
<ArrowDownCircle className="h-4 w-4 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/60 uppercase tracking-wide">Заявок</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{incomingLoading ? (
|
||||
<span className="inline-block h-6 w-8 bg-white/10 rounded animate-pulse" />
|
||||
) : (
|
||||
incomingRequestsCount
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* За месяц */}
|
||||
<Card className="glass-card p-3 hover:bg-white/5 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-green-500/20 border border-green-500/30">
|
||||
<TrendingUp className="h-4 w-4 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/60 uppercase tracking-wide">За месяц</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{loading ? (
|
||||
<span className="inline-block h-6 w-8 bg-white/10 rounded animate-pulse" />
|
||||
) : (
|
||||
counterparties.filter((org) => {
|
||||
const monthAgo = new Date()
|
||||
monthAgo.setMonth(monthAgo.getMonth() - 1)
|
||||
return new Date(org.createdAt) > monthAgo
|
||||
}).length
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Исходящих */}
|
||||
<Card className="glass-card p-3 hover:bg-white/5 transition-all duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-orange-500/20 border border-orange-500/30">
|
||||
<ArrowUpCircle className="h-4 w-4 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/60 uppercase tracking-wide">Исходящих</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{outgoingLoading ? (
|
||||
<span className="inline-block h-6 w-8 bg-white/10 rounded animate-pulse" />
|
||||
) : (
|
||||
outgoingRequestsCount
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Компактная партнерская ссылка */}
|
||||
{partnerLink && (
|
||||
<Card className="glass-card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-yellow-500/20 border border-yellow-500/30">
|
||||
<Gift className="h-4 w-4 text-yellow-400" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-white">Партнерская ссылка</h3>
|
||||
</div>
|
||||
<div className="text-xs text-white/60">
|
||||
Прямое деловое сотрудничество с автоматическим добавлением
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 px-3 py-2 glass-input rounded-lg text-white/60 font-mono text-sm truncate">
|
||||
{partnerLink || 'Загрузка...'}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onCopyPartnerLink && onCopyPartnerLink(partnerLink || '')}
|
||||
className="glass-button hover:bg-white/20 transition-all duration-200 px-3"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Копировать
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Фильтры и поиск */}
|
||||
<Card className="glass-card p-3">
|
||||
<div className="flex flex-col xl:flex-row gap-3">
|
||||
{/* Поиск */}
|
||||
<div className="flex-1">
|
||||
<GlassInput
|
||||
placeholder="Поиск по названию, ИНН или адресу..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
icon={Search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Фильтр по типу */}
|
||||
<div className="w-full xl:w-48">
|
||||
<Select value={typeFilter} onValueChange={onTypeFilterChange}>
|
||||
<SelectTrigger className="glass-input">
|
||||
<SelectValue placeholder="Тип организации" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все типы</SelectItem>
|
||||
<SelectItem value="FULFILLMENT">Фулфилмент</SelectItem>
|
||||
<SelectItem value="SELLER">Селлеры</SelectItem>
|
||||
<SelectItem value="LOGIST">Логистика</SelectItem>
|
||||
<SelectItem value="WHOLESALE">Поставщики</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Сортировка */}
|
||||
<div className="w-full xl:w-48">
|
||||
<Select value={sortField} onValueChange={onSort}>
|
||||
<SelectTrigger className="glass-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">По названию</SelectItem>
|
||||
<SelectItem value="date">По дате</SelectItem>
|
||||
<SelectItem value="inn">По ИНН</SelectItem>
|
||||
<SelectItem value="type">По типу</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Порядок сортировки */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSort?.(sortField)}
|
||||
className="glass-button"
|
||||
>
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Сброс фильтров */}
|
||||
{(searchQuery || typeFilter !== 'all') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSearchChange?.('')
|
||||
onTypeFilterChange?.('all')
|
||||
}}
|
||||
className="glass-button text-red-400 hover:text-red-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Статистика и быстрые фильтры */}
|
||||
<div className="flex items-center justify-between text-xs text-white/60 mt-3">
|
||||
<div>
|
||||
{filteredCount !== undefined && totalCount !== undefined ? (
|
||||
<>Показано {filteredCount} из {totalCount} контрагентов</>
|
||||
) : (
|
||||
<>Показано {counterparties.length} контрагентов</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Быстрые фильтры по типам */}
|
||||
<div className="flex gap-1">
|
||||
{(['FULFILLMENT', 'SELLER', 'LOGIST', 'WHOLESALE'] as const).map((type) => {
|
||||
const count = counterparties.filter(org => org.type === type).length
|
||||
return count > 0 ? (
|
||||
<Button
|
||||
key={type}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onTypeFilterChange?.(type)}
|
||||
className={`text-xs px-2 py-1 ${
|
||||
typeFilter === type
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: 'text-white/40 hover:text-white/70'
|
||||
}`}
|
||||
>
|
||||
{ORGANIZATION_TYPES[type]} ({count})
|
||||
</Button>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Пустое состояние */}
|
||||
{emptyState}
|
||||
|
||||
{/* Список контрагентов */}
|
||||
{counterparties.map((org) => (
|
||||
<Card key={org.id} className="glass-card p-6 hover:bg-white/5 transition-all duration-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
{/* Аватар организации */}
|
||||
<OrganizationAvatar
|
||||
organization={org}
|
||||
size="lg"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Основная информация */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{org.name || org.fullName}
|
||||
</h3>
|
||||
<Badge variant="outline" className="flex-shrink-0">
|
||||
{ORGANIZATION_TYPES[org.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* ИНН */}
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
ИНН: {org.inn}
|
||||
</p>
|
||||
|
||||
{/* Контактная информация */}
|
||||
<div className="space-y-1">
|
||||
{org.address && (
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span className="truncate">{org.address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{org.phones && org.phones.length > 0 && (
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Phone className="h-4 w-4" />
|
||||
<span>{org.phones[0].value}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{org.emails && org.emails.length > 0 && (
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Mail className="h-4 w-4" />
|
||||
<span>{org.emails[0].value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Дата добавления */}
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500 mt-3">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>
|
||||
Партнеры с {new Date(org.createdAt).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Действия */}
|
||||
<div className="flex items-center space-x-2 flex-shrink-0 ml-4">
|
||||
{onViewDetails && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewDetails(org)}
|
||||
>
|
||||
Подробнее
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRemove(org.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Статистика */}
|
||||
{counterparties.length > 0 && (
|
||||
<div className="text-center text-sm text-white/60 pt-4">
|
||||
Показано {counterparties.length} контрагент{counterparties.length === 1 ? '' :
|
||||
counterparties.length < 5 ? 'а' : 'ов'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Поиск новых организаций (интеграция функций из удаленной вкладки "Поиск") */}
|
||||
<Card className="glass-card p-4 mt-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Search className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="text-lg font-semibold text-white">Поиск новых партнеров</h3>
|
||||
</div>
|
||||
|
||||
{/* Фильтры поиска новых организаций */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<GlassInput
|
||||
placeholder="Поиск новых организаций по названию, ИНН..."
|
||||
value={searchNewQuery}
|
||||
onChange={(e) => onSearchNewChange?.(e.target.value)}
|
||||
icon={Search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-48">
|
||||
<Select value={searchNewTypeFilter} onValueChange={onSearchNewTypeFilterChange}>
|
||||
<SelectTrigger className="glass-input">
|
||||
<SelectValue placeholder="Тип организации" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все типы</SelectItem>
|
||||
<SelectItem value="FULFILLMENT">Фулфилмент</SelectItem>
|
||||
<SelectItem value="SELLER">Селлеры</SelectItem>
|
||||
<SelectItem value="LOGIST">Логистика</SelectItem>
|
||||
<SelectItem value="WHOLESALE">Поставщики</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты поиска новых организаций */}
|
||||
{searchLoading && (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="glass-card p-4 animate-pulse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-12 w-12 bg-white/10 rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-white/10 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-white/10 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="h-8 w-20 bg-white/10 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searchLoading && searchNewQuery && !searchResults.length && (
|
||||
<div className="text-center py-8">
|
||||
<div className="h-16 w-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Search className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white">Организации не найдены</h3>
|
||||
<p className="text-white/60 mt-1">Попробуйте изменить параметры поиска</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searchNewQuery && (
|
||||
<div className="text-center py-8">
|
||||
<div className="h-16 w-16 bg-blue-500/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-blue-500/30">
|
||||
<Search className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white">Поиск новых партнеров</h3>
|
||||
<p className="text-white/60 mt-1">Введите название или ИНН организации для поиска</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список найденных организаций */}
|
||||
{!searchLoading && searchResults.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{searchResults.map((org) => (
|
||||
<div key={org.id} className="glass-card p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<OrganizationAvatar organization={org} size="lg" className="flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-white truncate">
|
||||
{org.name || org.fullName}
|
||||
</h3>
|
||||
<Badge variant="outline">{ORGANIZATION_TYPES[org.type]}</Badge>
|
||||
|
||||
{org.isCounterparty && (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||
Уже партнер
|
||||
</Badge>
|
||||
)}
|
||||
{org.hasOutgoingRequest && (
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
|
||||
Заявка отправлена
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-white/60 mb-2">ИНН: {org.inn}</p>
|
||||
{org.address && (
|
||||
<p className="text-sm text-white/60 truncate">{org.address}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка отправки заявки */}
|
||||
<div className="flex items-center space-x-2 flex-shrink-0 ml-4">
|
||||
{!org.isCounterparty && !org.hasOutgoingRequest && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onSendRequest?.(org.id)}
|
||||
className="glass-button"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Отправить заявку
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-center text-sm text-white/60 pt-4">
|
||||
Найдено {searchResults.length} организаци{searchResults.length === 1 ? 'я' :
|
||||
searchResults.length < 5 ? 'и' : 'й'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CounterpartiesListBlock.displayName = 'CounterpartiesListBlock'
|
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Блок входящих заявок на партнерство
|
||||
* Отображает заявки с возможностью принятия или отклонения
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { ArrowDownCircle, Calendar, CheckCircle, XCircle } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
import { OrganizationAvatar } from '../../organization-avatar'
|
||||
import { ORGANIZATION_TYPES, type IncomingRequestsBlockProps } from '../types'
|
||||
|
||||
export const IncomingRequestsBlock = React.memo(function IncomingRequestsBlock({
|
||||
requests,
|
||||
loading,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: IncomingRequestsBlockProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i} className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-12 w-12 bg-gray-200 rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-8 w-16 bg-gray-200 rounded"></div>
|
||||
<div className="h-8 w-16 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!requests.length) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-16 w-16 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<ArrowDownCircle className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Входящих заявок нет</h3>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Когда другие организации отправят вам заявки, они появятся здесь
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{requests.map((request) => (
|
||||
<Card key={request.id} className="p-6 border-l-4 border-l-blue-500">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
{/* Аватар отправителя */}
|
||||
<OrganizationAvatar
|
||||
organization={request.sender}
|
||||
size="lg"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Информация о заявке */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{request.sender.name || request.sender.fullName}
|
||||
</h3>
|
||||
<Badge variant="outline">
|
||||
{ORGANIZATION_TYPES[request.sender.type]}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-blue-100 text-blue-800">
|
||||
Новая заявка
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* ИНН отправителя */}
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
ИНН: {request.sender.inn}
|
||||
</p>
|
||||
|
||||
{/* Сообщение заявки */}
|
||||
{request.message && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-gray-700">{request.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Дата заявки */}
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>
|
||||
Заявка от {new Date(request.createdAt).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Действия */}
|
||||
<div className="flex items-center space-x-2 flex-shrink-0 ml-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAccept(request.id)}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Принять
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onReject(request.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="text-center text-sm text-gray-500 pt-4">
|
||||
{requests.length} входящ{requests.length === 1 ? 'ая заявка' :
|
||||
requests.length < 5 ? 'ие заявки' : 'их заявок'} ожида{requests.length === 1 ? 'ет' : 'ют'} рассмотрения
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
IncomingRequestsBlock.displayName = 'IncomingRequestsBlock'
|
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Блок исходящих заявок на партнерство
|
||||
* Отображает отправленные заявки с возможностью отмены
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { ArrowUpCircle, Calendar, X } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
import { OrganizationAvatar } from '../../organization-avatar'
|
||||
import { ORGANIZATION_TYPES, REQUEST_STATUSES, type OutgoingRequestsBlockProps } from '../types'
|
||||
|
||||
export const OutgoingRequestsBlock = React.memo(function OutgoingRequestsBlock({
|
||||
requests,
|
||||
loading,
|
||||
onCancel,
|
||||
}: OutgoingRequestsBlockProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i} className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-12 w-12 bg-gray-200 rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="h-8 w-16 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!requests.length) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-16 w-16 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<ArrowUpCircle className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Исходящих заявок нет</h3>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Найдите организации для сотрудничества и отправьте им заявки
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{requests.map((request) => (
|
||||
<Card key={request.id} className="p-6 border-l-4 border-l-orange-500">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
{/* Аватар получателя */}
|
||||
<OrganizationAvatar
|
||||
organization={request.receiver}
|
||||
size="lg"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Информация о заявке */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{request.receiver.name || request.receiver.fullName}
|
||||
</h3>
|
||||
<Badge variant="outline">
|
||||
{ORGANIZATION_TYPES[request.receiver.type]}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
request.status === 'PENDING'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: request.status === 'ACCEPTED'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}
|
||||
>
|
||||
{REQUEST_STATUSES[request.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* ИНН получателя */}
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
ИНН: {request.receiver.inn}
|
||||
</p>
|
||||
|
||||
{/* Сообщение заявки */}
|
||||
{request.message && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-gray-700">{request.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Дата заявки */}
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>
|
||||
Отправлена {new Date(request.createdAt).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Действия */}
|
||||
<div className="flex items-center space-x-2 flex-shrink-0 ml-4">
|
||||
{request.status === 'PENDING' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onCancel(request.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Отменить
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{request.status === 'ACCEPTED' && (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||
Принята
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{request.status === 'REJECTED' && (
|
||||
<Badge variant="secondary" className="bg-red-100 text-red-800">
|
||||
Отклонена
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="text-center text-sm text-gray-500 pt-4">
|
||||
{requests.length} исходящ{requests.length === 1 ? 'ая заявка' :
|
||||
requests.length < 5 ? 'ие заявки' : 'их заявок'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
OutgoingRequestsBlock.displayName = 'OutgoingRequestsBlock'
|
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Блок управления партнерскими ссылками
|
||||
* Отображает ссылку, статистику и возможность копирования
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { Copy, Gift, TrendingUp, ExternalLink } from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { GlassInput } from '@/components/ui/input'
|
||||
|
||||
import type { PartnerLinksBlockProps } from '../types'
|
||||
|
||||
export const PartnerLinksBlock = React.memo(function PartnerLinksBlock({
|
||||
partnerLink,
|
||||
loading,
|
||||
onCopyLink,
|
||||
onGenerateLink,
|
||||
}: PartnerLinksBlockProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-10 bg-gray-200 rounded"></div>
|
||||
<div className="flex space-x-4">
|
||||
<div className="h-16 bg-gray-200 rounded flex-1"></div>
|
||||
<div className="h-16 bg-gray-200 rounded flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!partnerLink) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-16 w-16 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Gift className="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Партнерская ссылка недоступна</h3>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Обратитесь к администратору для получения партнерской ссылки
|
||||
</p>
|
||||
</div>
|
||||
{onGenerateLink && (
|
||||
<Button onClick={onGenerateLink} className="mt-4">
|
||||
<Gift className="h-4 w-4 mr-2" />
|
||||
Создать ссылку
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Партнерская ссылка */}
|
||||
<Card className="glass-card p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Gift className="h-5 w-5 text-purple-500" />
|
||||
<h3 className="text-lg font-semibold text-white">Ваша партнерская ссылка</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-white/60">
|
||||
Поделитесь этой ссылкой с другими организациями для прямого партнерского сотрудничества
|
||||
</p>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex-1 px-3 py-2 glass-input rounded-lg text-white/60 font-mono text-sm truncate">
|
||||
{partnerLink || 'Загрузка...'}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onCopyLink(partnerLink || '')}
|
||||
className="glass-button"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Копировать
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-white/50">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span>
|
||||
Организации, перешедшие по этой ссылке, автоматически становятся вашими партнерами
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Информация о бонусах */}
|
||||
<Card className="glass-card p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="h-8 w-8 bg-purple-500/20 rounded-lg flex items-center justify-center flex-shrink-0 border border-purple-500/30">
|
||||
<Gift className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-lg font-semibold text-white mb-2">
|
||||
Партнерская программа
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm text-white/70">
|
||||
<p>• За каждую организацию, зарегистрированную по вашей ссылке: <strong className="text-yellow-400">100 сфер</strong></p>
|
||||
<p>• Автоматическое добавление в контрагенты для упрощения сотрудничества</p>
|
||||
<p>• Приоритетная поддержка для партнерских организаций</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
PartnerLinksBlock.displayName = 'PartnerLinksBlock'
|
@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Блок поиска организаций для отправки заявок на партнерство
|
||||
* Включает поиск, фильтрацию и отправку заявок
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { Search, Send } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { GlassInput } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { OrganizationAvatar } from '../../organization-avatar'
|
||||
import { ORGANIZATION_TYPES, type SearchOrganizationsBlockProps } from '../types'
|
||||
|
||||
export const SearchOrganizationsBlock = React.memo(function SearchOrganizationsBlock({
|
||||
searchResults,
|
||||
loading,
|
||||
onSendRequest,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
}: SearchOrganizationsBlockProps) {
|
||||
const [sendingTo, setSendingTo] = useState<string | null>(null)
|
||||
const [customMessage, setCustomMessage] = useState('')
|
||||
const [showMessageForm, setShowMessageForm] = useState<string | null>(null)
|
||||
|
||||
const handleSendRequest = async (organizationId: string) => {
|
||||
setSendingTo(organizationId)
|
||||
try {
|
||||
await onSendRequest(organizationId, customMessage || undefined)
|
||||
setCustomMessage('')
|
||||
setShowMessageForm(null)
|
||||
} catch (error) {
|
||||
console.error('Error sending request:', error)
|
||||
} finally {
|
||||
setSendingTo(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowMessageForm = (organizationId: string) => {
|
||||
setShowMessageForm(organizationId)
|
||||
setCustomMessage('Предлагаем партнерское сотрудничество')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Фильтры поиска */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<GlassInput
|
||||
placeholder="Поиск по названию, ИНН или адресу..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
icon={Search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-48">
|
||||
<Select value={typeFilter} onValueChange={onTypeFilterChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Тип организации" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все типы</SelectItem>
|
||||
<SelectItem value="FULFILLMENT">Фулфилмент</SelectItem>
|
||||
<SelectItem value="SELLER">Селлеры</SelectItem>
|
||||
<SelectItem value="LOGIST">Логистика</SelectItem>
|
||||
<SelectItem value="WHOLESALE">Поставщики</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Результаты поиска */}
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-12 w-12 bg-gray-200 rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="h-8 w-20 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && searchQuery && !searchResults.length && (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-16 w-16 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<Search className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Организации не найдены</h3>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Попробуйте изменить параметры поиска
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!searchQuery && (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-16 w-16 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Search className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Поиск партнеров</h3>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Введите название, ИНН или адрес организации для поиска
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Список найденных организаций */}
|
||||
{!loading && searchResults.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{searchResults.map((org) => (
|
||||
<Card key={org.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
{/* Аватар организации */}
|
||||
<OrganizationAvatar
|
||||
organization={org}
|
||||
size="lg"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
{/* Информация об организации */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{org.name || org.fullName}
|
||||
</h3>
|
||||
<Badge variant="outline">
|
||||
{ORGANIZATION_TYPES[org.type]}
|
||||
</Badge>
|
||||
|
||||
{/* Статусы */}
|
||||
{org.isCounterparty && (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||
Уже партнер
|
||||
</Badge>
|
||||
)}
|
||||
{org.hasOutgoingRequest && (
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
|
||||
Заявка отправлена
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
ИНН: {org.inn}
|
||||
</p>
|
||||
|
||||
{org.address && (
|
||||
<p className="text-sm text-gray-600 truncate">
|
||||
{org.address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Действия */}
|
||||
<div className="flex items-center space-x-2 flex-shrink-0 ml-4">
|
||||
{!org.isCounterparty && !org.hasOutgoingRequest && (
|
||||
<>
|
||||
{showMessageForm === org.id ? (
|
||||
<div className="w-80 space-y-3">
|
||||
<Textarea
|
||||
placeholder="Сообщение для заявки (необязательно)"
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowMessageForm(null)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSendRequest(org.id)}
|
||||
disabled={sendingTo === org.id}
|
||||
>
|
||||
{sendingTo === org.id ? (
|
||||
'Отправка...'
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Отправить
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleShowMessageForm(org.id)}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Отправить заявку
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Статистика поиска */}
|
||||
<div className="text-center text-sm text-gray-500 pt-4">
|
||||
Найдено {searchResults.length} организаци{searchResults.length === 1 ? 'я' :
|
||||
searchResults.length < 5 ? 'и' : 'й'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SearchOrganizationsBlock.displayName = 'SearchOrganizationsBlock'
|
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Hook для управления действиями с контрагентами
|
||||
* Обрабатывает все GraphQL mutations и их результаты
|
||||
*/
|
||||
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
RESPOND_TO_COUNTERPARTY_REQUEST,
|
||||
CANCEL_COUNTERPARTY_REQUEST,
|
||||
REMOVE_COUNTERPARTY,
|
||||
SEND_COUNTERPARTY_REQUEST,
|
||||
} from '@/graphql/mutations'
|
||||
import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_INCOMING_REQUESTS,
|
||||
GET_OUTGOING_REQUESTS,
|
||||
SEARCH_ORGANIZATIONS,
|
||||
} from '@/graphql/queries'
|
||||
|
||||
import type { UseCounterpartyActionsReturn } from '../types'
|
||||
|
||||
export function useCounterpartyActions(): UseCounterpartyActionsReturn {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Mutation для ответа на заявку (принять/отклонить)
|
||||
const [respondToRequestMutation] = useMutation(RESPOND_TO_COUNTERPARTY_REQUEST, {
|
||||
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,
|
||||
})
|
||||
|
||||
// Mutation для отмены исходящей заявки
|
||||
const [cancelRequestMutation] = useMutation(CANCEL_COUNTERPARTY_REQUEST, {
|
||||
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,
|
||||
})
|
||||
|
||||
// Mutation для удаления контрагента
|
||||
const [removeCounterpartyMutation] = useMutation(REMOVE_COUNTERPARTY, {
|
||||
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,
|
||||
})
|
||||
|
||||
// Mutation для отправки заявки на партнерство
|
||||
const [sendRequestMutation] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
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 acceptRequest = useCallback(async (requestId: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { data } = await respondToRequestMutation({
|
||||
variables: {
|
||||
requestId,
|
||||
response: 'ACCEPTED',
|
||||
},
|
||||
})
|
||||
|
||||
if (data?.respondToCounterpartyRequest?.success) {
|
||||
toast.success('Заявка принята! Организация добавлена в контрагенты')
|
||||
} else {
|
||||
const errorMessage = data?.respondToCounterpartyRequest?.message || 'Не удалось принять заявку'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error accepting request:', error)
|
||||
const errorMessage = 'Ошибка при принятии заявки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [respondToRequestMutation])
|
||||
|
||||
// Отклонить заявку на партнерство
|
||||
const rejectRequest = useCallback(async (requestId: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { data } = await respondToRequestMutation({
|
||||
variables: {
|
||||
requestId,
|
||||
response: 'REJECTED',
|
||||
},
|
||||
})
|
||||
|
||||
if (data?.respondToCounterpartyRequest?.success) {
|
||||
toast.success('Заявка отклонена')
|
||||
} else {
|
||||
const errorMessage = data?.respondToCounterpartyRequest?.message || 'Не удалось отклонить заявку'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rejecting request:', error)
|
||||
const errorMessage = 'Ошибка при отклонении заявки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [respondToRequestMutation])
|
||||
|
||||
// Отменить исходящую заявку
|
||||
const cancelRequest = useCallback(async (requestId: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { data } = await cancelRequestMutation({
|
||||
variables: { requestId },
|
||||
})
|
||||
|
||||
if (data?.cancelCounterpartyRequest?.success) {
|
||||
toast.success('Заявка отменена')
|
||||
} else {
|
||||
const errorMessage = data?.cancelCounterpartyRequest?.message || 'Не удалось отменить заявку'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error canceling request:', error)
|
||||
const errorMessage = 'Ошибка при отмене заявки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [cancelRequestMutation])
|
||||
|
||||
// Отправить заявку на партнерство
|
||||
const sendRequest = useCallback(async (organizationId: string, message?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { data } = await sendRequestMutation({
|
||||
variables: {
|
||||
organizationId,
|
||||
message: message || 'Предлагаем партнерское сотрудничество',
|
||||
},
|
||||
})
|
||||
|
||||
if (data?.sendCounterpartyRequest?.success) {
|
||||
toast.success('Заявка отправлена! Ожидайте ответа от организации')
|
||||
} else {
|
||||
const errorMessage = data?.sendCounterpartyRequest?.message || 'Не удалось отправить заявку'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending request:', error)
|
||||
const errorMessage = 'Ошибка при отправке заявки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sendRequestMutation])
|
||||
|
||||
// Удалить контрагента
|
||||
const removeCounterparty = useCallback(async (organizationId: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { data } = await removeCounterpartyMutation({
|
||||
variables: { organizationId },
|
||||
})
|
||||
|
||||
if (data?.removeCounterparty?.success) {
|
||||
toast.success('Контрагент удален из списка')
|
||||
} else {
|
||||
const errorMessage = data?.removeCounterparty?.message || 'Не удалось удалить контрагента'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing counterparty:', error)
|
||||
const errorMessage = 'Ошибка при удалении контрагента'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [removeCounterpartyMutation])
|
||||
|
||||
return {
|
||||
// Действия с заявками
|
||||
acceptRequest,
|
||||
rejectRequest,
|
||||
cancelRequest,
|
||||
sendRequest,
|
||||
|
||||
// Действия с контрагентами
|
||||
removeCounterparty,
|
||||
|
||||
// Состояние
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Hook для загрузки и управления данными контрагентов
|
||||
* Обрабатывает все GraphQL queries и кэширование
|
||||
*/
|
||||
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_INCOMING_REQUESTS,
|
||||
GET_OUTGOING_REQUESTS,
|
||||
SEARCH_ORGANIZATIONS,
|
||||
} from '@/graphql/queries'
|
||||
import { GET_MY_PARTNER_LINK } from '@/graphql/referral-queries'
|
||||
|
||||
import type {
|
||||
UseCounterpartyDataReturn,
|
||||
Organization,
|
||||
CounterpartyRequest,
|
||||
PartnerLink,
|
||||
OrganizationType,
|
||||
} from '../types'
|
||||
|
||||
export function useCounterpartyData(): UseCounterpartyDataReturn {
|
||||
const [searchResults, setSearchResults] = useState<Organization[]>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Основные queries
|
||||
const {
|
||||
data: counterpartiesData,
|
||||
loading: counterpartiesLoading,
|
||||
refetch: refetchCounterparties,
|
||||
} = useQuery(GET_MY_COUNTERPARTIES, {
|
||||
errorPolicy: 'all',
|
||||
onError: (error) => {
|
||||
console.error('Error loading counterparties:', error)
|
||||
setError('Ошибка загрузки контрагентов')
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data: incomingData,
|
||||
loading: incomingLoading,
|
||||
refetch: refetchIncoming,
|
||||
} = useQuery(GET_INCOMING_REQUESTS, {
|
||||
errorPolicy: 'all',
|
||||
onError: (error) => {
|
||||
console.error('Error loading incoming requests:', error)
|
||||
setError('Ошибка загрузки входящих заявок')
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data: outgoingData,
|
||||
loading: outgoingLoading,
|
||||
refetch: refetchOutgoing,
|
||||
} = useQuery(GET_OUTGOING_REQUESTS, {
|
||||
errorPolicy: 'all',
|
||||
onError: (error) => {
|
||||
console.error('Error loading outgoing requests:', error)
|
||||
setError('Ошибка загрузки исходящих заявок')
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
data: partnerLinkData,
|
||||
loading: partnerLinkLoading,
|
||||
refetch: refetchPartnerLink,
|
||||
} = useQuery(GET_MY_PARTNER_LINK, {
|
||||
errorPolicy: 'all',
|
||||
onError: (error) => {
|
||||
console.error('Error loading partner link:', error)
|
||||
setError('Ошибка загрузки партнерской ссылки')
|
||||
},
|
||||
})
|
||||
|
||||
// Поиск организаций
|
||||
const searchOrganizations = useCallback(async (query: string, type?: OrganizationType) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setSearchLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Используем Apollo Client напрямую для поиска
|
||||
const { apolloClient } = await import('@/lib/apollo-client')
|
||||
|
||||
const variables: { search: string; type?: string } = { search: query }
|
||||
if (type && type !== 'all') {
|
||||
variables.type = type
|
||||
}
|
||||
|
||||
const { data } = await apolloClient.query({
|
||||
query: SEARCH_ORGANIZATIONS,
|
||||
variables,
|
||||
fetchPolicy: 'network-only', // Всегда получать свежие данные поиска
|
||||
})
|
||||
|
||||
if (data?.searchOrganizations?.success) {
|
||||
setSearchResults(data.searchOrganizations.organizations || [])
|
||||
} else {
|
||||
setError(data?.searchOrganizations?.message || 'Ошибка поиска организаций')
|
||||
setSearchResults([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search organizations error:', error)
|
||||
setError('Ошибка поиска организаций')
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Обновление всех данных
|
||||
const refetchAll = useCallback(async () => {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
refetchCounterparties(),
|
||||
refetchIncoming(),
|
||||
refetchOutgoing(),
|
||||
refetchPartnerLink(),
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Error refetching data:', error)
|
||||
setError('Ошибка обновления данных')
|
||||
}
|
||||
}, [refetchCounterparties, refetchIncoming, refetchOutgoing, refetchPartnerLink])
|
||||
|
||||
// Извлечение данных из responses
|
||||
const counterparties: Organization[] = counterpartiesData?.getMyCounterparties || []
|
||||
const incomingRequests: CounterpartyRequest[] = incomingData?.getIncomingCounterpartyRequests || []
|
||||
const outgoingRequests: CounterpartyRequest[] = outgoingData?.getOutgoingCounterpartyRequests || []
|
||||
const partnerLink: string | null = partnerLinkData?.myPartnerLink || null
|
||||
|
||||
return {
|
||||
// Данные
|
||||
counterparties,
|
||||
incomingRequests,
|
||||
outgoingRequests,
|
||||
searchResults,
|
||||
partnerLink,
|
||||
|
||||
// Состояние загрузки
|
||||
counterpartiesLoading,
|
||||
incomingLoading,
|
||||
outgoingLoading,
|
||||
searchLoading,
|
||||
partnerLinkLoading,
|
||||
|
||||
// Ошибки
|
||||
error,
|
||||
|
||||
// Методы
|
||||
refetchAll,
|
||||
searchOrganizations,
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Hook для фильтрации и поиска контрагентов
|
||||
* Обрабатывает локальные фильтры и поиск через API
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
|
||||
// Простая реализация debounce для hook
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
import type {
|
||||
UseCounterpartyFiltersReturn,
|
||||
OrganizationType,
|
||||
} from '../types'
|
||||
|
||||
interface UseCounterpartyFiltersProps {
|
||||
onSearch?: (query: string, type?: OrganizationType) => void
|
||||
}
|
||||
|
||||
export function useCounterpartyFilters({
|
||||
onSearch,
|
||||
}: UseCounterpartyFiltersProps): UseCounterpartyFiltersReturn {
|
||||
// Состояние фильтров для поиска
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<OrganizationType>('all')
|
||||
|
||||
// Debounced поиск
|
||||
const debouncedSearch = useDebounce(searchQuery, 500)
|
||||
|
||||
// Автоматический поиск при изменении запроса или типа
|
||||
useEffect(() => {
|
||||
if (onSearch) {
|
||||
onSearch(debouncedSearch, typeFilter === 'all' ? undefined : typeFilter)
|
||||
}
|
||||
}, [debouncedSearch, typeFilter, onSearch])
|
||||
|
||||
// Обработчики изменений
|
||||
const handleSearchChange = useCallback((query: string) => {
|
||||
setSearchQuery(query)
|
||||
}, [])
|
||||
|
||||
const handleTypeFilterChange = useCallback((type: OrganizationType) => {
|
||||
setTypeFilter(type)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
typeFilter,
|
||||
debouncedSearch,
|
||||
handleSearchChange,
|
||||
handleTypeFilterChange,
|
||||
}
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Hook для управления партнерскими ссылками
|
||||
* Обрабатывает генерацию, копирование и статистику ссылок
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { UsePartnerLinksReturn, PartnerLink } from '../types'
|
||||
|
||||
interface UsePartnerLinksProps {
|
||||
partnerLink: PartnerLink | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function usePartnerLinks({ partnerLink, loading }: UsePartnerLinksProps): UsePartnerLinksReturn {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Генерация новой партнерской ссылки
|
||||
const generateLink = useCallback(async () => {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// TODO: Реализовать мутацию для генерации новой ссылки
|
||||
// const { data } = await generatePartnerLinkMutation()
|
||||
|
||||
// Пока что показываем уведомление
|
||||
toast.info('Функция генерации ссылок будет доступна в следующем обновлении')
|
||||
} catch (error) {
|
||||
console.error('Error generating partner link:', error)
|
||||
const errorMessage = 'Ошибка при генерации партнерской ссылки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Копирование ссылки в буфер обмена
|
||||
const copyToClipboard = useCallback(async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
// Проверяем поддержку Clipboard API
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success('Ссылка скопирована в буфер обмена')
|
||||
return true
|
||||
} else {
|
||||
// Fallback для старых браузеров
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
const successful = document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
|
||||
if (successful) {
|
||||
toast.success('Ссылка скопирована в буфер обмена')
|
||||
return true
|
||||
} else {
|
||||
throw new Error('Copy command failed')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying to clipboard:', error)
|
||||
toast.error('Не удалось скопировать ссылку')
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Обновление статистики по ссылке
|
||||
const refreshStats = useCallback(async () => {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// TODO: Реализовать обновление статистики
|
||||
// const { data } = await refreshPartnerLinkStatsMutation()
|
||||
|
||||
// Пока что показываем уведомление
|
||||
toast.info('Статистика обновлена')
|
||||
} catch (error) {
|
||||
console.error('Error refreshing partner link stats:', error)
|
||||
const errorMessage = 'Ошибка при обновлении статистики'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Данные
|
||||
partnerLink,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Методы
|
||||
generateLink,
|
||||
copyToClipboard,
|
||||
refreshStats,
|
||||
}
|
||||
}
|
251
src/components/market/market-counterparties/index.tsx
Normal file
251
src/components/market/market-counterparties/index.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Основной компонент управления контрагентами (Модульная архитектура)
|
||||
* Объединяет все hooks и блоки в единую систему
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { Users, ArrowDownCircle, ArrowUpCircle } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
// Hooks
|
||||
import { useCounterpartyActions } from './hooks/useCounterpartyActions'
|
||||
import { useCounterpartyData } from './hooks/useCounterpartyData'
|
||||
import { useCounterpartyFilters } from './hooks/useCounterpartyFilters'
|
||||
|
||||
// UI Blocks
|
||||
import { CounterpartiesListBlock } from './blocks/CounterpartiesListBlock'
|
||||
import { IncomingRequestsBlock } from './blocks/IncomingRequestsBlock'
|
||||
import { OutgoingRequestsBlock } from './blocks/OutgoingRequestsBlock'
|
||||
|
||||
// Types
|
||||
import type { Organization } from './types'
|
||||
|
||||
interface MarketCounterpartiesProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function MarketCounterparties({ className }: MarketCounterpartiesProps) {
|
||||
// Состояние активной вкладки
|
||||
const [activeTab, setActiveTab] = useState('counterparties')
|
||||
|
||||
// Data Hooks
|
||||
const {
|
||||
counterparties,
|
||||
incomingRequests,
|
||||
outgoingRequests,
|
||||
searchResults,
|
||||
partnerLink,
|
||||
counterpartiesLoading,
|
||||
incomingLoading,
|
||||
outgoingLoading,
|
||||
searchLoading,
|
||||
partnerLinkLoading,
|
||||
_error,
|
||||
refetchAll,
|
||||
searchOrganizations,
|
||||
} = useCounterpartyData()
|
||||
|
||||
// Action Hooks
|
||||
const {
|
||||
removeCounterparty,
|
||||
acceptRequest,
|
||||
rejectRequest,
|
||||
cancelRequest,
|
||||
sendRequest,
|
||||
loading: _actionLoading,
|
||||
} = useCounterpartyActions()
|
||||
|
||||
// Filter Hooks
|
||||
const {
|
||||
_searchQuery,
|
||||
typeFilter,
|
||||
debouncedSearch,
|
||||
handleSearchChange,
|
||||
handleTypeFilterChange,
|
||||
} = useCounterpartyFilters({
|
||||
onSearch: searchOrganizations,
|
||||
})
|
||||
|
||||
// Unified loading states for blocks
|
||||
const loading = {
|
||||
counterparties: counterpartiesLoading,
|
||||
incoming: incomingLoading,
|
||||
outgoing: outgoingLoading,
|
||||
search: searchLoading,
|
||||
}
|
||||
|
||||
// Обработчики действий с callback для обновления данных
|
||||
const handleRemoveCounterparty = async (id: string) => {
|
||||
try {
|
||||
await removeCounterparty(id)
|
||||
await refetchAll()
|
||||
toast.success('Контрагент удален')
|
||||
} catch (_error) {
|
||||
toast.error('Ошибка удаления контрагента')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAcceptRequest = async (id: string) => {
|
||||
try {
|
||||
await acceptRequest(id)
|
||||
await refetchAll()
|
||||
toast.success('Заявка принята')
|
||||
} catch (_error) {
|
||||
toast.error('Ошибка принятия заявки')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRejectRequest = async (id: string) => {
|
||||
try {
|
||||
await rejectRequest(id)
|
||||
await refetchAll()
|
||||
toast.success('Заявка отклонена')
|
||||
} catch (_error) {
|
||||
toast.error('Ошибка отклонения заявки')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelRequest = async (id: string) => {
|
||||
try {
|
||||
await cancelRequest(id)
|
||||
await refetchAll()
|
||||
toast.success('Заявка отменена')
|
||||
} catch (_error) {
|
||||
toast.error('Ошибка отмены заявки')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendRequest = async (organizationId: string, message?: string) => {
|
||||
try {
|
||||
await sendRequest(organizationId, message)
|
||||
await refetchAll()
|
||||
toast.success('Заявка отправлена')
|
||||
} catch (_error) {
|
||||
toast.error('Ошибка отправки заявки')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLink = (url: string) => {
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success('Ссылка скопирована в буфер обмена')
|
||||
}
|
||||
|
||||
const handleGenerateLink = async () => {
|
||||
try {
|
||||
// TODO: Реализовать создание партнерской ссылки
|
||||
await refetchAll()
|
||||
toast.success('Партнерская ссылка создана')
|
||||
} catch (_error) {
|
||||
toast.error('Ошибка создания ссылки')
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчик просмотра деталей организации
|
||||
const handleViewDetails = (organization: Organization) => {
|
||||
// TODO: Реализовать модальное окно с деталями организации
|
||||
toast.info(`Детали организации: ${organization.name || organization.fullName}`)
|
||||
}
|
||||
|
||||
// Подсчет уведомлений для вкладок
|
||||
const pendingIncomingCount = incomingRequests.filter(req => req.status === 'PENDING').length
|
||||
const pendingOutgoingCount = outgoingRequests.filter(req => req.status === 'PENDING').length
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/10">
|
||||
{/* Контрагенты */}
|
||||
<TabsTrigger
|
||||
value="counterparties"
|
||||
className="flex items-center space-x-2 data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-300 text-white/70"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
<span>Контрагенты</span>
|
||||
{counterparties.length > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs bg-blue-500/20 text-blue-300 rounded-full border border-blue-500/30">
|
||||
{counterparties.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Входящие заявки */}
|
||||
<TabsTrigger
|
||||
value="incoming"
|
||||
className={`flex items-center space-x-2 data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-300 text-white/70 relative ${
|
||||
pendingIncomingCount > 0 ? 'animate-pulse ring-2 ring-green-400/50' : ''
|
||||
}`}
|
||||
>
|
||||
<ArrowDownCircle className="h-4 w-4" />
|
||||
<span>Входящие</span>
|
||||
{pendingIncomingCount > 0 && (
|
||||
<>
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs bg-green-500/20 text-green-300 rounded-full border border-green-500/30">
|
||||
{pendingIncomingCount}
|
||||
</span>
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
</>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Исходящие заявки */}
|
||||
<TabsTrigger
|
||||
value="outgoing"
|
||||
className="flex items-center space-x-2 data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-300 text-white/70"
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
<span>Исходящие</span>
|
||||
{pendingOutgoingCount > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs bg-orange-500/20 text-orange-300 rounded-full border border-orange-500/30">
|
||||
{pendingOutgoingCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Контент вкладок */}
|
||||
|
||||
{/* Список контрагентов */}
|
||||
<TabsContent value="counterparties" className="mt-6">
|
||||
<CounterpartiesListBlock
|
||||
counterparties={counterparties}
|
||||
loading={loading.counterparties}
|
||||
onRemove={handleRemoveCounterparty}
|
||||
onViewDetails={handleViewDetails}
|
||||
incomingRequestsCount={incomingRequests.length}
|
||||
outgoingRequestsCount={outgoingRequests.length}
|
||||
incomingLoading={loading.incoming}
|
||||
outgoingLoading={loading.outgoing}
|
||||
partnerLink={partnerLink}
|
||||
onCopyPartnerLink={handleCopyLink}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Входящие заявки */}
|
||||
<TabsContent value="incoming" className="mt-6">
|
||||
<IncomingRequestsBlock
|
||||
requests={incomingRequests}
|
||||
loading={loading.incoming}
|
||||
onAccept={handleAcceptRequest}
|
||||
onReject={handleRejectRequest}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Исходящие заявки */}
|
||||
<TabsContent value="outgoing" className="mt-6">
|
||||
<OutgoingRequestsBlock
|
||||
requests={outgoingRequests}
|
||||
loading={loading.outgoing}
|
||||
onCancel={handleCancelRequest}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Экспорт для обратной совместимости
|
||||
export { MarketCounterparties }
|
258
src/components/market/market-counterparties/types/index.ts
Normal file
258
src/components/market/market-counterparties/types/index.ts
Normal file
@ -0,0 +1,258 @@
|
||||
/**
|
||||
* TypeScript интерфейсы для модуля управления контрагентами
|
||||
* Модуляризовано согласно MODULAR_ARCHITECTURE_PATTERN.md
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// ОСНОВНЫЕ ДОМЕННЫЕ СУЩНОСТИ
|
||||
// =============================================================================
|
||||
|
||||
export 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 }>
|
||||
createdAt: string
|
||||
users?: Array<{ id: string; avatar?: string }>
|
||||
// Флаги для поиска
|
||||
isCounterparty?: boolean
|
||||
hasOutgoingRequest?: boolean
|
||||
isCurrentUser?: boolean
|
||||
}
|
||||
|
||||
export interface CounterpartyRequest {
|
||||
id: string
|
||||
message?: string
|
||||
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'
|
||||
createdAt: string
|
||||
sender: Organization
|
||||
receiver: Organization
|
||||
}
|
||||
|
||||
export interface PartnerLink {
|
||||
id: string
|
||||
code: string
|
||||
url: string
|
||||
clickCount?: number
|
||||
registrationCount?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ТИПЫ ДЛЯ ФИЛЬТРАЦИИ И СОРТИРОВКИ
|
||||
// =============================================================================
|
||||
|
||||
export type SortField = 'name' | 'date' | 'inn' | 'type'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
export type OrganizationType = 'all' | 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
|
||||
export interface FilterConfig {
|
||||
searchQuery: string
|
||||
typeFilter: OrganizationType
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROPS ДЛЯ БЛОК-КОМПОНЕНТОВ
|
||||
// =============================================================================
|
||||
|
||||
export interface CounterpartiesListBlockProps {
|
||||
counterparties: Organization[]
|
||||
loading: boolean
|
||||
onRemove: (id: string) => Promise<void>
|
||||
onViewDetails?: (organization: Organization) => void
|
||||
// Статистические данные
|
||||
incomingRequestsCount?: number
|
||||
outgoingRequestsCount?: number
|
||||
incomingLoading?: boolean
|
||||
outgoingLoading?: boolean
|
||||
// Партнерская ссылка
|
||||
partnerLink?: string | null
|
||||
onCopyPartnerLink?: (url: string) => void
|
||||
// Фильтрация существующих контрагентов
|
||||
searchQuery?: string
|
||||
onSearchChange?: (query: string) => void
|
||||
typeFilter?: OrganizationType
|
||||
onTypeFilterChange?: (type: OrganizationType) => void
|
||||
sortField?: SortField
|
||||
sortOrder?: SortOrder
|
||||
onSort?: (field: SortField) => void
|
||||
filteredCount?: number
|
||||
totalCount?: number
|
||||
// Поиск новых организаций (из старой вкладки "Поиск")
|
||||
searchResults?: Organization[]
|
||||
searchLoading?: boolean
|
||||
onSendRequest?: (organizationId: string, message?: string) => Promise<void>
|
||||
searchNewQuery?: string
|
||||
onSearchNewChange?: (query: string) => void
|
||||
searchNewTypeFilter?: OrganizationType
|
||||
onSearchNewTypeFilterChange?: (type: OrganizationType) => void
|
||||
}
|
||||
|
||||
export interface IncomingRequestsBlockProps {
|
||||
requests: CounterpartyRequest[]
|
||||
loading: boolean
|
||||
onAccept: (requestId: string) => Promise<void>
|
||||
onReject: (requestId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export interface OutgoingRequestsBlockProps {
|
||||
requests: CounterpartyRequest[]
|
||||
loading: boolean
|
||||
onCancel: (requestId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export interface SearchOrganizationsBlockProps {
|
||||
searchResults: Organization[]
|
||||
loading: boolean
|
||||
onSendRequest: (organizationId: string, message?: string) => Promise<void>
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
typeFilter: OrganizationType
|
||||
onTypeFilterChange: (type: OrganizationType) => void
|
||||
}
|
||||
|
||||
export interface PartnerLinksBlockProps {
|
||||
partnerLink: string | null
|
||||
loading: boolean
|
||||
onCopyLink: (url: string) => void
|
||||
onGenerateLink?: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface CounterpartyFiltersBlockProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
typeFilter: OrganizationType
|
||||
onTypeFilterChange: (type: OrganizationType) => void
|
||||
sortField: SortField
|
||||
sortOrder: SortOrder
|
||||
onSort: (field: SortField) => void
|
||||
resultsCount: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ТИПЫ ДЛЯ HOOKS
|
||||
// =============================================================================
|
||||
|
||||
export interface UseCounterpartyDataReturn {
|
||||
// Данные
|
||||
counterparties: Organization[]
|
||||
incomingRequests: CounterpartyRequest[]
|
||||
outgoingRequests: CounterpartyRequest[]
|
||||
searchResults: Organization[]
|
||||
partnerLink: PartnerLink | null
|
||||
|
||||
// Состояние загрузки
|
||||
counterpartiesLoading: boolean
|
||||
incomingLoading: boolean
|
||||
outgoingLoading: boolean
|
||||
searchLoading: boolean
|
||||
partnerLinkLoading: boolean
|
||||
|
||||
// Ошибки
|
||||
error: string | null
|
||||
|
||||
// Методы
|
||||
refetchAll: () => Promise<void>
|
||||
searchOrganizations: (query: string, type?: OrganizationType) => Promise<void>
|
||||
}
|
||||
|
||||
export interface UseCounterpartyActionsReturn {
|
||||
// Действия с заявками
|
||||
acceptRequest: (requestId: string) => Promise<void>
|
||||
rejectRequest: (requestId: string) => Promise<void>
|
||||
cancelRequest: (requestId: string) => Promise<void>
|
||||
sendRequest: (organizationId: string, message?: string) => Promise<void>
|
||||
|
||||
// Действия с контрагентами
|
||||
removeCounterparty: (organizationId: string) => Promise<void>
|
||||
|
||||
// Состояние
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface UseCounterpartyFiltersReturn {
|
||||
// Текущие фильтры
|
||||
searchQuery: string
|
||||
typeFilter: OrganizationType
|
||||
debouncedSearch: string
|
||||
|
||||
// Методы обновления фильтров
|
||||
handleSearchChange: (query: string) => void
|
||||
handleTypeFilterChange: (type: OrganizationType) => void
|
||||
}
|
||||
|
||||
export interface UsePartnerLinksReturn {
|
||||
// Данные
|
||||
partnerLink: PartnerLink | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
// Методы
|
||||
generateLink: () => Promise<void>
|
||||
copyToClipboard: (text: string) => Promise<boolean>
|
||||
refreshStats: () => Promise<void>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ТИПЫ ДЛЯ GraphQL
|
||||
// =============================================================================
|
||||
|
||||
export interface CounterpartyMutationVariables {
|
||||
requestId?: string
|
||||
organizationId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SearchOrganizationsVariables {
|
||||
type?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// УТИЛИТАРНЫЕ ТИПЫ
|
||||
// =============================================================================
|
||||
|
||||
export interface TabConfig {
|
||||
key: string
|
||||
label: string
|
||||
count?: number
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// КОНСТАНТЫ И ЕНУМЫ
|
||||
// =============================================================================
|
||||
|
||||
export const ORGANIZATION_TYPES = {
|
||||
FULFILLMENT: 'Фулфилмент',
|
||||
SELLER: 'Селлер',
|
||||
LOGIST: 'Логистика',
|
||||
WHOLESALE: 'Поставщик',
|
||||
} as const
|
||||
|
||||
export const REQUEST_STATUSES = {
|
||||
PENDING: 'Ожидает',
|
||||
ACCEPTED: 'Принято',
|
||||
REJECTED: 'Отклонено',
|
||||
CANCELLED: 'Отменено',
|
||||
} as const
|
||||
|
||||
export const SORT_FIELDS = {
|
||||
name: 'Название',
|
||||
date: 'Дата',
|
||||
inn: 'ИНН',
|
||||
type: 'Тип',
|
||||
} as const
|
Reference in New Issue
Block a user