
🎨 Унификация UI: - Полная унификация визуала вкладок Рефералы и Мои контрагенты - Исправлены React Hooks ошибки в sidebar.tsx - Убрана лишняя обертка glass-card в partners-dashboard.tsx - Исправлена цветовая схема (purple → yellow) - Табличный формат вместо карточного grid-layout - Компактные блоки статистики (4 метрики в ряд) - Правильная прозрачность glass-morphism эффектов 📚 Документация: - Переименован referral-system-rules.md → partners-rules.md - Детальные UI/UX правила в partners-rules.md - Правила унификации в visual-design-rules.md - Обновлен current-session.md - Создан development-diary.md 🚀 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
821 lines
36 KiB
TypeScript
821 lines
36 KiB
TypeScript
'use client'
|
||
|
||
import { useQuery, useMutation } from '@apollo/client'
|
||
import {
|
||
Users,
|
||
ArrowUpCircle,
|
||
ArrowDownCircle,
|
||
Search,
|
||
Filter,
|
||
SortAsc,
|
||
SortDesc,
|
||
Calendar,
|
||
Building,
|
||
Phone,
|
||
Mail,
|
||
MapPin,
|
||
X,
|
||
Copy,
|
||
Gift,
|
||
TrendingUp,
|
||
} from 'lucide-react'
|
||
import React, { useState, useMemo } from 'react'
|
||
import { toast } from 'sonner'
|
||
|
||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import { RESPOND_TO_COUNTERPARTY_REQUEST, CANCEL_COUNTERPARTY_REQUEST, REMOVE_COUNTERPARTY } from '@/graphql/mutations'
|
||
import {
|
||
GET_MY_COUNTERPARTIES,
|
||
GET_INCOMING_REQUESTS,
|
||
GET_OUTGOING_REQUESTS,
|
||
SEARCH_ORGANIZATIONS,
|
||
GET_MY_PARTNER_LINK,
|
||
} from '@/graphql/queries'
|
||
|
||
import { OrganizationAvatar } from './organization-avatar'
|
||
|
||
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 }>
|
||
}
|
||
|
||
interface CounterpartyRequest {
|
||
id: string
|
||
message?: string
|
||
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'
|
||
createdAt: string
|
||
sender: Organization
|
||
receiver: Organization
|
||
}
|
||
|
||
type SortField = 'name' | 'date' | 'inn' | 'type'
|
||
type SortOrder = 'asc' | 'desc'
|
||
|
||
export function MarketCounterparties() {
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||
const [sortField, setSortField] = useState<SortField>('name')
|
||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||
|
||
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
||
const { data: incomingData, loading: incomingLoading } = useQuery(GET_INCOMING_REQUESTS)
|
||
const { data: outgoingData, loading: outgoingLoading } = useQuery(GET_OUTGOING_REQUESTS)
|
||
const { data: partnerLinkData } = useQuery(GET_MY_PARTNER_LINK)
|
||
|
||
const [respondToRequest] = 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,
|
||
})
|
||
|
||
const [cancelRequest] = 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,
|
||
})
|
||
|
||
const [removeCounterparty] = 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,
|
||
})
|
||
|
||
// Функция копирования партнерской ссылки
|
||
const copyPartnerLink = async () => {
|
||
try {
|
||
const partnerLink = partnerLinkData?.myPartnerLink
|
||
if (!partnerLink) {
|
||
toast.error('Партнерская ссылка недоступна')
|
||
return
|
||
}
|
||
await navigator.clipboard.writeText(partnerLink)
|
||
toast.success('Партнерская ссылка скопирована!', {
|
||
description: 'Поделитесь ей для прямого делового сотрудничества',
|
||
})
|
||
} catch {
|
||
toast.error('Не удалось скопировать ссылку')
|
||
}
|
||
}
|
||
|
||
// Фильтрация и сортировка контрагентов
|
||
const filteredAndSortedCounterparties = useMemo(() => {
|
||
const filtered = (counterpartiesData?.myCounterparties || []).filter((org: Organization) => {
|
||
const matchesSearch =
|
||
!searchQuery ||
|
||
org.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
org.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
org.inn.includes(searchQuery) ||
|
||
org.address?.toLowerCase().includes(searchQuery.toLowerCase())
|
||
|
||
const matchesType = typeFilter === 'all' || org.type === typeFilter
|
||
|
||
return matchesSearch && matchesType
|
||
})
|
||
|
||
// Сортировка
|
||
filtered.sort((a: Organization, b: Organization) => {
|
||
let aValue: string | number
|
||
let bValue: string | number
|
||
|
||
switch (sortField) {
|
||
case 'name':
|
||
aValue = (a.name || a.fullName || '').toLowerCase()
|
||
bValue = (b.name || b.fullName || '').toLowerCase()
|
||
break
|
||
case 'date':
|
||
aValue = new Date(a.createdAt).getTime()
|
||
bValue = new Date(b.createdAt).getTime()
|
||
break
|
||
case 'inn':
|
||
aValue = a.inn
|
||
bValue = b.inn
|
||
break
|
||
case 'type':
|
||
aValue = a.type
|
||
bValue = b.type
|
||
break
|
||
default:
|
||
return 0
|
||
}
|
||
|
||
if (sortOrder === 'asc') {
|
||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0
|
||
} else {
|
||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0
|
||
}
|
||
})
|
||
|
||
return filtered
|
||
}, [counterpartiesData?.myCounterparties, searchQuery, typeFilter, sortField, sortOrder])
|
||
|
||
const handleAcceptRequest = async (requestId: string) => {
|
||
try {
|
||
await respondToRequest({
|
||
variables: { requestId, accept: true },
|
||
})
|
||
} catch (error) {
|
||
console.error('Ошибка при принятии заявки:', error)
|
||
}
|
||
}
|
||
|
||
const handleRejectRequest = async (requestId: string) => {
|
||
try {
|
||
await respondToRequest({
|
||
variables: { requestId, accept: false },
|
||
})
|
||
} catch (error) {
|
||
console.error('Ошибка при отклонении заявки:', error)
|
||
}
|
||
}
|
||
|
||
const handleCancelRequest = async (requestId: string) => {
|
||
try {
|
||
await cancelRequest({
|
||
variables: { requestId },
|
||
})
|
||
} catch (error) {
|
||
console.error('Ошибка при отмене заявки:', error)
|
||
}
|
||
}
|
||
|
||
const handleRemoveCounterparty = async (organizationId: string) => {
|
||
try {
|
||
await removeCounterparty({
|
||
variables: { organizationId },
|
||
})
|
||
} catch (error) {
|
||
console.error('Ошибка при удалении контрагента:', error)
|
||
}
|
||
}
|
||
|
||
const formatDate = (dateString: string) => {
|
||
if (!dateString) return ''
|
||
try {
|
||
let date: Date
|
||
|
||
// Проверяем, является ли строка числом (Unix timestamp)
|
||
if (/^\d+$/.test(dateString)) {
|
||
// Если это Unix timestamp в миллисекундах
|
||
const timestamp = parseInt(dateString, 10)
|
||
date = new Date(timestamp)
|
||
} else {
|
||
// Обычная строка даты
|
||
date = new Date(dateString)
|
||
}
|
||
|
||
if (isNaN(date.getTime())) {
|
||
return 'Неверная дата'
|
||
}
|
||
|
||
return date.toLocaleDateString('ru-RU', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
})
|
||
} catch {
|
||
return 'Ошибка даты'
|
||
}
|
||
}
|
||
|
||
const clearFilters = () => {
|
||
setSearchQuery('')
|
||
setTypeFilter('all')
|
||
setSortField('name')
|
||
setSortOrder('asc')
|
||
}
|
||
|
||
const hasActiveFilters = searchQuery || typeFilter !== 'all' || sortField !== 'name' || sortOrder !== 'asc'
|
||
|
||
const counterparties = counterpartiesData?.myCounterparties || []
|
||
const incomingRequests = incomingData?.incomingRequests || []
|
||
const outgoingRequests = outgoingData?.outgoingRequests || []
|
||
|
||
const getTypeLabel = (type: string) => {
|
||
switch (type) {
|
||
case 'FULFILLMENT':
|
||
return 'Фулфилмент'
|
||
case 'SELLER':
|
||
return 'Селлер'
|
||
case 'LOGIST':
|
||
return 'Логистика'
|
||
case 'WHOLESALE':
|
||
return 'Поставщик'
|
||
default:
|
||
return type
|
||
}
|
||
}
|
||
|
||
const getTypeBadgeStyles = (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'
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
<div className="flex-1 overflow-hidden">
|
||
<Tabs defaultValue="counterparties" className="h-full flex flex-col">
|
||
<TabsList className="grid w-full grid-cols-3 bg-white/5 border-white/10">
|
||
<TabsTrigger
|
||
value="counterparties"
|
||
className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-300"
|
||
>
|
||
<Users className="h-4 w-4 mr-2" />
|
||
Контрагенты ({counterparties.length})
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
value="incoming"
|
||
className={`data-[state=active]:bg-green-500/20 data-[state=active]:text-green-300 relative ${incomingRequests.length > 0 ? 'ring-2 ring-green-400/50 animate-pulse' : ''}`}
|
||
>
|
||
<ArrowDownCircle className="h-4 w-4 mr-2" />
|
||
Входящие ({incomingRequests.length})
|
||
{incomingRequests.length > 0 && (
|
||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></div>
|
||
)}
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
value="outgoing"
|
||
className="data-[state=active]:bg-orange-500/20 data-[state=active]:text-orange-300"
|
||
>
|
||
<ArrowUpCircle className="h-4 w-4 mr-2" />
|
||
Исходящие ({outgoingRequests.length})
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="counterparties" className="flex-1 overflow-hidden mt-3 flex flex-col">
|
||
<div className="h-full flex flex-col space-y-4">
|
||
{/* Компактный блок с партнерской ссылкой */}
|
||
<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">
|
||
{partnerLinkData?.myPartnerLink || 'http://localhost:3000/register?partner=LOADING'}
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={copyPartnerLink}
|
||
className="glass-button hover:bg-white/20 transition-all duration-200 px-3"
|
||
>
|
||
<Copy className="h-4 w-4 mr-1" />
|
||
Копировать
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Компактная статистика */}
|
||
<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">
|
||
{counterpartiesLoading ? (
|
||
<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" />
|
||
) : (
|
||
incomingRequests.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-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">
|
||
{counterpartiesLoading ? (
|
||
<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-yellow-500/20 border border-yellow-500/30">
|
||
<ArrowUpCircle 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">
|
||
{outgoingLoading ? (
|
||
<span className="inline-block h-6 w-8 bg-white/10 rounded animate-pulse" />
|
||
) : (
|
||
outgoingRequests.length
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Компактные фильтры */}
|
||
<Card className="glass-card p-3">
|
||
<div className="flex flex-col xl:flex-row gap-3">
|
||
{/* Поиск */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||
<GlassInput
|
||
placeholder="Поиск..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-10 h-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Фильтры и сортировка */}
|
||
<div className="flex gap-2">
|
||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||
<SelectTrigger className="glass-input text-white border-white/20 h-9 min-w-[120px]">
|
||
<Filter className="h-3 w-3 mr-1" />
|
||
<SelectValue placeholder="Тип" />
|
||
</SelectTrigger>
|
||
<SelectContent className="glass-card border-white/20">
|
||
<SelectItem value="all">Все</SelectItem>
|
||
<SelectItem value="FULFILLMENT">Фулфилмент</SelectItem>
|
||
<SelectItem value="SELLER">Селлер</SelectItem>
|
||
<SelectItem value="LOGIST">Логистика</SelectItem>
|
||
<SelectItem value="WHOLESALE">Поставщик</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<Select value={sortField} onValueChange={(value) => setSortField(value as SortField)}>
|
||
<SelectTrigger className="glass-input text-white border-white/20 h-9 min-w-[100px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="glass-card border-white/20">
|
||
<SelectItem value="name">Название</SelectItem>
|
||
<SelectItem value="date">Дата</SelectItem>
|
||
<SelectItem value="inn">ИНН</SelectItem>
|
||
<SelectItem value="type">Тип</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||
className="glass-input border-white/20 text-white hover:bg-white/10 h-9 w-9 p-0"
|
||
>
|
||
{sortOrder === 'asc' ? <SortAsc className="h-3 w-3" /> : <SortDesc className="h-3 w-3" />}
|
||
</Button>
|
||
|
||
{hasActiveFilters && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={clearFilters}
|
||
className="text-white/60 hover:text-white hover:bg-white/10 h-9 w-9 p-0"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Статистика и быстрые фильтры */}
|
||
<div className="flex items-center justify-between text-xs">
|
||
<div className="text-white/60">
|
||
{filteredAndSortedCounterparties.length} из {counterparties.length}
|
||
</div>
|
||
|
||
<div className="flex gap-1">
|
||
{['FULFILLMENT', 'SELLER', 'LOGIST', 'WHOLESALE'].map((type) => {
|
||
const count = counterparties.filter((org: Organization) => org.type === type).length
|
||
if (count === 0) return null
|
||
|
||
return (
|
||
<Button
|
||
key={type}
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setTypeFilter(typeFilter === type ? 'all' : type)}
|
||
className={`h-6 px-2 text-xs ${
|
||
typeFilter === type
|
||
? getTypeBadgeStyles(type) + ' border'
|
||
: 'text-white/50 hover:text-white hover:bg-white/10'
|
||
}`}
|
||
>
|
||
{getTypeLabel(type)} ({count})
|
||
</Button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Таблица контрагентов */}
|
||
<Card className="glass-card flex-1 overflow-hidden">
|
||
<div className="h-full overflow-auto">
|
||
<div className="p-6 space-y-3">
|
||
{/* Заголовок таблицы */}
|
||
<div className="p-4 rounded-xl bg-gradient-to-r from-white/5 to-white/10 border border-white/10">
|
||
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-white/80">
|
||
<div className="col-span-2 flex items-center gap-2">
|
||
<Calendar className="h-4 w-4 text-blue-400" />
|
||
<span>Дата добавления</span>
|
||
</div>
|
||
<div className="col-span-3 flex items-center gap-2">
|
||
<Building className="h-4 w-4 text-green-400" />
|
||
<span>Организация</span>
|
||
</div>
|
||
<div className="col-span-1 text-center flex items-center justify-center">
|
||
<span>Тип</span>
|
||
</div>
|
||
<div className="col-span-3 flex items-center gap-2">
|
||
<Phone className="h-4 w-4 text-purple-400" />
|
||
<span>Контакты</span>
|
||
</div>
|
||
<div className="col-span-2 flex items-center gap-2">
|
||
<MapPin className="h-4 w-4 text-orange-400" />
|
||
<span>Адрес</span>
|
||
</div>
|
||
<div className="col-span-1 text-center flex items-center justify-center">
|
||
<span>Действия</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Строки таблицы */}
|
||
{counterpartiesLoading ? (
|
||
<div className="flex items-center justify-center p-8">
|
||
<div className="text-white/60">Загрузка...</div>
|
||
</div>
|
||
) : filteredAndSortedCounterparties.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-64">
|
||
{counterparties.length === 0 ? (
|
||
<>
|
||
<Users className="h-12 w-12 text-white/20 mb-2" />
|
||
<p className="text-white/60">У вас пока нет контрагентов</p>
|
||
<p className="text-white/40 text-sm mt-1">Перейдите на другие вкладки, чтобы найти партнеров</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Search className="h-12 w-12 text-white/20 mb-2" />
|
||
<p className="text-white/60">Ничего не найдено</p>
|
||
<p className="text-white/40 text-sm mt-1">
|
||
Попробуйте изменить параметры поиска или фильтрации
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : (
|
||
filteredAndSortedCounterparties.map((organization: Organization) => (
|
||
<div key={organization.id} className="p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-all duration-200 border border-white/10">
|
||
<div className="grid grid-cols-12 gap-4 items-center">
|
||
<div className="col-span-2 text-white/80">
|
||
<div className="flex items-center gap-2">
|
||
<Calendar className="h-3 w-3 text-white/40" />
|
||
<span className="text-sm">{formatDate(organization.createdAt)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="col-span-3">
|
||
<div className="flex items-center gap-3">
|
||
<OrganizationAvatar organization={organization} size="sm" />
|
||
<div>
|
||
<p className="text-white font-medium text-sm">
|
||
{organization.name || organization.fullName}
|
||
</p>
|
||
<p className="text-white/60 text-xs flex items-center gap-1">
|
||
<Building className="h-3 w-3" />
|
||
{organization.inn}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-span-1 text-center">
|
||
<Badge className={getTypeBadgeStyles(organization.type) + ' text-xs'}>
|
||
{getTypeLabel(organization.type)}
|
||
</Badge>
|
||
</div>
|
||
<div className="col-span-3">
|
||
<div className="space-y-1">
|
||
{organization.phones && organization.phones.length > 0 && (
|
||
<div className="flex items-center text-white/60 text-xs">
|
||
<Phone className="h-3 w-3 mr-2" />
|
||
<span>{organization.phones[0].value}</span>
|
||
</div>
|
||
)}
|
||
{organization.emails && organization.emails.length > 0 && (
|
||
<div className="flex items-center text-white/60 text-xs">
|
||
<Mail className="h-3 w-3 mr-2" />
|
||
<span className="truncate">{organization.emails[0].value}</span>
|
||
</div>
|
||
)}
|
||
{!organization.phones?.length && !organization.emails?.length && (
|
||
<span className="text-white/40 text-xs">Нет контактов</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="col-span-2">
|
||
{organization.address ? (
|
||
<p className="text-white/60 text-xs line-clamp-2">{organization.address}</p>
|
||
) : (
|
||
<span className="text-white/40 text-xs">Не указан</span>
|
||
)}
|
||
</div>
|
||
<div className="col-span-1 text-center">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => handleRemoveCounterparty(organization.id)}
|
||
className="hover:bg-red-500/20 text-white/60 hover:text-red-300 h-8 w-8 p-0"
|
||
title="Удалить из контрагентов"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="incoming" className="flex-1 overflow-auto mt-4">
|
||
{incomingLoading ? (
|
||
<div className="flex items-center justify-center p-8">
|
||
<div className="text-white/60">Загрузка...</div>
|
||
</div>
|
||
) : incomingRequests.length === 0 ? (
|
||
<div className="glass-card p-8">
|
||
<div className="text-center">
|
||
<ArrowDownCircle className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||
<p className="text-white/60">Нет входящих заявок</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{incomingRequests.map((request: CounterpartyRequest) => (
|
||
<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">
|
||
<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={getTypeBadgeStyles(request.sender.type)}>
|
||
{getTypeLabel(request.sender.type)}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
|
||
{request.sender.address && (
|
||
<div className="flex items-center text-white/60 text-sm">
|
||
<span className="truncate">{request.sender.address}</span>
|
||
</div>
|
||
)}
|
||
{request.message && (
|
||
<div className="p-2 bg-white/5 rounded border border-white/10">
|
||
<p className="text-white/80 text-sm italic">"{request.message}"</p>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center text-white/40 text-xs">
|
||
<span>Заявка от {formatDate(request.createdAt)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex space-x-2">
|
||
<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 flex-1"
|
||
>
|
||
Принять
|
||
</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 flex-1"
|
||
>
|
||
Отклонить
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="outgoing" className="flex-1 overflow-auto mt-4">
|
||
{outgoingLoading ? (
|
||
<div className="flex items-center justify-center p-8">
|
||
<div className="text-white/60">Загрузка...</div>
|
||
</div>
|
||
) : outgoingRequests.length === 0 ? (
|
||
<div className="glass-card p-8">
|
||
<div className="text-center">
|
||
<ArrowUpCircle className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||
<p className="text-white/60">Нет исходящих заявок</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{outgoingRequests.map((request: CounterpartyRequest) => (
|
||
<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">
|
||
<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={getTypeBadgeStyles(request.receiver.type)}>
|
||
{getTypeLabel(request.receiver.type)}
|
||
</Badge>
|
||
<Badge
|
||
className={
|
||
request.status === 'PENDING'
|
||
? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30'
|
||
: request.status === 'REJECTED'
|
||
? 'bg-red-500/20 text-red-300 border-red-500/30'
|
||
: 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||
}
|
||
>
|
||
{request.status === 'PENDING'
|
||
? 'Ожидает ответа'
|
||
: request.status === 'REJECTED'
|
||
? 'Отклонено'
|
||
: request.status}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
|
||
{request.receiver.address && (
|
||
<div className="flex items-center text-white/60 text-sm">
|
||
<span className="truncate">{request.receiver.address}</span>
|
||
</div>
|
||
)}
|
||
{request.message && (
|
||
<div className="p-2 bg-white/5 rounded border border-white/10">
|
||
<p className="text-white/80 text-sm italic">"{request.message}"</p>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center text-white/40 text-xs">
|
||
<span>Отправлено {formatDate(request.createdAt)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{request.status === 'PENDING' && (
|
||
<Button
|
||
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 w-full"
|
||
>
|
||
Отменить заявку
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|