Добавлены новые зависимости, обновлены стили и улучшена структура проекта. Обновлен README с описанием функционала и технологий. Реализована анимация и адаптивный дизайн. Настроена авторизация с использованием Apollo Client.
This commit is contained in:
326
src/components/market/market-counterparties.tsx
Normal file
326
src/components/market/market-counterparties.tsx
Normal file
@ -0,0 +1,326 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Users,
|
||||
Clock,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowUpCircle,
|
||||
ArrowDownCircle
|
||||
} from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { GET_MY_COUNTERPARTIES, GET_INCOMING_REQUESTS, GET_OUTGOING_REQUESTS } from '@/graphql/queries'
|
||||
import { RESPOND_TO_COUNTERPARTY_REQUEST, CANCEL_COUNTERPARTY_REQUEST, REMOVE_COUNTERPARTY } from '@/graphql/mutations'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function MarketCounterparties() {
|
||||
const { data: counterpartiesData, loading: counterpartiesLoading, refetch: refetchCounterparties } = useQuery(GET_MY_COUNTERPARTIES)
|
||||
const { data: incomingData, loading: incomingLoading, refetch: refetchIncoming } = useQuery(GET_INCOMING_REQUESTS)
|
||||
const { data: outgoingData, loading: outgoingLoading, refetch: refetchOutgoing } = useQuery(GET_OUTGOING_REQUESTS)
|
||||
|
||||
const [respondToRequest] = useMutation(RESPOND_TO_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetchIncoming()
|
||||
refetchCounterparties()
|
||||
}
|
||||
})
|
||||
|
||||
const [cancelRequest] = useMutation(CANCEL_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetchOutgoing()
|
||||
}
|
||||
})
|
||||
|
||||
const [removeCounterparty] = useMutation(REMOVE_COUNTERPARTY, {
|
||||
onCompleted: () => {
|
||||
refetchCounterparties()
|
||||
}
|
||||
})
|
||||
|
||||
const handleAcceptRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({
|
||||
variables: { requestId, response: 'ACCEPTED' }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка при принятии заявки:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRejectRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({
|
||||
variables: { requestId, response: 'REJECTED' }
|
||||
})
|
||||
} 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 (error) {
|
||||
return 'Ошибка даты'
|
||||
}
|
||||
}
|
||||
|
||||
const counterparties = counterpartiesData?.myCounterparties || []
|
||||
const incomingRequests = incomingData?.incomingRequests || []
|
||||
const outgoingRequests = outgoingData?.outgoingRequests || []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<Users className="h-6 w-6 text-blue-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Мои контрагенты</h3>
|
||||
<p className="text-white/60 text-sm">Управление контрагентами и заявками</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<ArrowDownCircle className="h-4 w-4 mr-2" />
|
||||
Входящие ({incomingRequests.length})
|
||||
</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-auto mt-4">
|
||||
{counterpartiesLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-white/60">Загрузка...</div>
|
||||
</div>
|
||||
) : counterparties.length === 0 ? (
|
||||
<div className="glass-card p-8">
|
||||
<div className="text-center">
|
||||
<Users className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">У вас пока нет контрагентов</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
Перейдите на другие вкладки, чтобы найти партнеров
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{counterparties.map((organization: Organization) => (
|
||||
<OrganizationCard
|
||||
key={organization.id}
|
||||
organization={organization}
|
||||
onRemove={handleRemoveCounterparty}
|
||||
showRemoveButton={true}
|
||||
/>
|
||||
))}
|
||||
</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="space-y-4">
|
||||
{incomingRequests.map((request: CounterpartyRequest) => (
|
||||
<Card key={request.id} className="glass-card p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
||||
{(request.sender.name || request.sender.fullName || 'O').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-medium">
|
||||
{request.sender.name || request.sender.fullName}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">ИНН: {request.sender.inn}</p>
|
||||
{request.message && (
|
||||
<p className="text-white/80 text-sm mt-2 italic">"{request.message}"</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Clock className="h-3 w-3 text-white/40" />
|
||||
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 ml-4">
|
||||
<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"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRejectRequest(request.id)}
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</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="space-y-4">
|
||||
{outgoingRequests.map((request: CounterpartyRequest) => (
|
||||
<Card key={request.id} className="glass-card p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
||||
{(request.receiver.name || request.receiver.fullName || 'O').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-medium">
|
||||
{request.receiver.name || request.receiver.fullName}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">ИНН: {request.receiver.inn}</p>
|
||||
{request.message && (
|
||||
<p className="text-white/80 text-sm mt-2 italic">"{request.message}"</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-3 w-3 text-white/40" />
|
||||
<span className="text-white/40 text-xs">{formatDate(request.createdAt)}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{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 ml-4"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
97
src/components/market/market-dashboard.tsx
Normal file
97
src/components/market/market-dashboard.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { MarketCounterparties } from './market-counterparties'
|
||||
import { MarketFulfillment } from './market-fulfillment'
|
||||
import { MarketSellers } from './market-sellers'
|
||||
import { MarketLogistics } from './market-logistics'
|
||||
import { MarketWholesale } from './market-wholesale'
|
||||
|
||||
export function MarketDashboard() {
|
||||
return (
|
||||
<div className="h-screen bg-gradient-smooth flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Заголовок - фиксированная высота */}
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Маркет</h1>
|
||||
<p className="text-white/70 text-sm">Управление контрагентами и поиск партнеров</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент с табами */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs defaultValue="counterparties" className="h-full flex flex-col">
|
||||
<TabsList className="grid w-full grid-cols-5 bg-white/5 backdrop-blur border-white/10 flex-shrink-0">
|
||||
<TabsTrigger
|
||||
value="counterparties"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||
>
|
||||
Мои контрагенты
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="fulfillment"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||
>
|
||||
Фулфилмент
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sellers"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||
>
|
||||
Селлеры
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="logistics"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||
>
|
||||
Логистика
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="wholesale"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70"
|
||||
>
|
||||
Оптовик
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="counterparties" className="flex-1 overflow-hidden mt-6">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<MarketCounterparties />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="fulfillment" className="flex-1 overflow-hidden mt-6">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<MarketFulfillment />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sellers" className="flex-1 overflow-hidden mt-6">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<MarketSellers />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logistics" className="flex-1 overflow-hidden mt-6">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<MarketLogistics />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="wholesale" className="flex-1 overflow-hidden mt-6">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<MarketWholesale />
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
125
src/components/market/market-fulfillment.tsx
Normal file
125
src/components/market/market-fulfillment.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Package } from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function MarketFulfillment() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const { data, loading, refetch } = useQuery(SEARCH_ORGANIZATIONS, {
|
||||
variables: { type: 'FULFILLMENT', search: searchTerm || null }
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
refetch({ type: 'FULFILLMENT', search: searchTerm || null })
|
||||
}
|
||||
|
||||
const handleSendRequest = async (organizationId: string, message: string) => {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки заявки:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const organizations = data?.searchOrganizations || []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4 overflow-hidden">
|
||||
{/* Поиск */}
|
||||
<div className="flex space-x-4 flex-shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
||||
<Input
|
||||
placeholder="Поиск фулфилментов по названию или ИНН..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer"
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Найти
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Заголовок с иконкой */}
|
||||
<div className="flex items-center space-x-3 flex-shrink-0 mb-4">
|
||||
<Package className="h-6 w-6 text-blue-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Фулфилмент-центры</h3>
|
||||
<p className="text-white/60 text-sm">Найдите и добавьте фулфилмент-центры в контрагенты</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты поиска */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-white/60">Поиск...</div>
|
||||
</div>
|
||||
) : organizations.length === 0 ? (
|
||||
<div className="glass-card p-8">
|
||||
<div className="text-center">
|
||||
<Package className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">
|
||||
{searchTerm ? 'Фулфилмент-центры не найдены' : 'Введите запрос для поиска фулфилментов'}
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
Попробуйте изменить условия поиска
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{organizations.map((organization: Organization) => (
|
||||
<OrganizationCard
|
||||
key={organization.id}
|
||||
organization={organization}
|
||||
onSendRequest={handleSendRequest}
|
||||
actionButtonText="Добавить"
|
||||
actionButtonColor="blue"
|
||||
requestSending={sendingRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
125
src/components/market/market-logistics.tsx
Normal file
125
src/components/market/market-logistics.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Truck } from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function MarketLogistics() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const { data, loading, refetch } = useQuery(SEARCH_ORGANIZATIONS, {
|
||||
variables: { type: 'LOGIST', search: searchTerm || null }
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
refetch({ type: 'LOGIST', search: searchTerm || null })
|
||||
}
|
||||
|
||||
const handleSendRequest = async (organizationId: string, message: string) => {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки заявки:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const organizations = data?.searchOrganizations || []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4 overflow-hidden">
|
||||
{/* Поиск */}
|
||||
<div className="flex space-x-4 flex-shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
||||
<Input
|
||||
placeholder="Поиск логистических компаний по названию или ИНН..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="bg-orange-500/20 hover:bg-orange-500/30 text-orange-300 border-orange-500/30 cursor-pointer"
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Найти
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Заголовок с иконкой */}
|
||||
<div className="flex items-center space-x-3 flex-shrink-0 mb-4">
|
||||
<Truck className="h-6 w-6 text-orange-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Логистика</h3>
|
||||
<p className="text-white/60 text-sm">Найдите и добавьте логистические компании в контрагенты</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты поиска */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-white/60">Поиск...</div>
|
||||
</div>
|
||||
) : organizations.length === 0 ? (
|
||||
<div className="glass-card p-8">
|
||||
<div className="text-center">
|
||||
<Truck className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">
|
||||
{searchTerm ? 'Логистические компании не найдены' : 'Введите запрос для поиска'}
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
Попробуйте изменить условия поиска
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{organizations.map((organization: Organization) => (
|
||||
<OrganizationCard
|
||||
key={organization.id}
|
||||
organization={organization}
|
||||
onSendRequest={handleSendRequest}
|
||||
actionButtonText="Добавить"
|
||||
actionButtonColor="yellow"
|
||||
requestSending={sendingRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
125
src/components/market/market-sellers.tsx
Normal file
125
src/components/market/market-sellers.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, ShoppingCart } from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function MarketSellers() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const { data, loading, refetch } = useQuery(SEARCH_ORGANIZATIONS, {
|
||||
variables: { type: 'SELLER', search: searchTerm || null }
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
refetch({ type: 'SELLER', search: searchTerm || null })
|
||||
}
|
||||
|
||||
const handleSendRequest = async (organizationId: string, message: string) => {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки заявки:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const organizations = data?.searchOrganizations || []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4 overflow-hidden">
|
||||
{/* Поиск */}
|
||||
<div className="flex space-x-4 flex-shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
||||
<Input
|
||||
placeholder="Поиск селлеров по названию или ИНН..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30 cursor-pointer"
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Найти
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Заголовок с иконкой */}
|
||||
<div className="flex items-center space-x-3 flex-shrink-0 mb-4">
|
||||
<ShoppingCart className="h-6 w-6 text-green-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Селлеры</h3>
|
||||
<p className="text-white/60 text-sm">Найдите и добавьте селлеров в контрагенты</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты поиска */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-white/60">Поиск...</div>
|
||||
</div>
|
||||
) : organizations.length === 0 ? (
|
||||
<div className="glass-card p-8">
|
||||
<div className="text-center">
|
||||
<ShoppingCart className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">
|
||||
{searchTerm ? 'Селлеры не найдены' : 'Введите запрос для поиска селлеров'}
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
Попробуйте изменить условия поиска
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{organizations.map((organization: Organization) => (
|
||||
<OrganizationCard
|
||||
key={organization.id}
|
||||
organization={organization}
|
||||
onSendRequest={handleSendRequest}
|
||||
actionButtonText="Добавить"
|
||||
actionButtonColor="orange"
|
||||
requestSending={sendingRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
125
src/components/market/market-wholesale.tsx
Normal file
125
src/components/market/market-wholesale.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search, Boxes } from 'lucide-react'
|
||||
import { OrganizationCard } from './organization-card'
|
||||
import { SEARCH_ORGANIZATIONS } from '@/graphql/queries'
|
||||
import { SEND_COUNTERPARTY_REQUEST } from '@/graphql/mutations'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function MarketWholesale() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const { data, loading, refetch } = useQuery(SEARCH_ORGANIZATIONS, {
|
||||
variables: { type: 'WHOLESALE', search: searchTerm || null }
|
||||
})
|
||||
|
||||
const [sendRequest, { loading: sendingRequest }] = useMutation(SEND_COUNTERPARTY_REQUEST, {
|
||||
onCompleted: () => {
|
||||
refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
refetch({ type: 'WHOLESALE', search: searchTerm || null })
|
||||
}
|
||||
|
||||
const handleSendRequest = async (organizationId: string, message: string) => {
|
||||
try {
|
||||
await sendRequest({
|
||||
variables: {
|
||||
receiverId: organizationId,
|
||||
message: message || 'Заявка на добавление в контрагенты'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки заявки:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const organizations = data?.searchOrganizations || []
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4 overflow-hidden">
|
||||
{/* Поиск */}
|
||||
<div className="flex space-x-4 flex-shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-white/40" />
|
||||
<Input
|
||||
placeholder="Поиск оптовых компаний по названию или ИНН..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-10 glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border-purple-500/30 cursor-pointer"
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Найти
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Заголовок с иконкой */}
|
||||
<div className="flex items-center space-x-3 flex-shrink-0 mb-4">
|
||||
<Boxes className="h-6 w-6 text-purple-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Оптовики</h3>
|
||||
<p className="text-white/60 text-sm">Найдите и добавьте оптовые компании в контрагенты</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты поиска */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-white/60">Поиск...</div>
|
||||
</div>
|
||||
) : organizations.length === 0 ? (
|
||||
<div className="glass-card p-8">
|
||||
<div className="text-center">
|
||||
<Boxes className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">
|
||||
{searchTerm ? 'Оптовые компании не найдены' : 'Введите запрос для поиска оптовиков'}
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
Попробуйте изменить условия поиска
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{organizations.map((organization: Organization) => (
|
||||
<OrganizationCard
|
||||
key={organization.id}
|
||||
organization={organization}
|
||||
onSendRequest={handleSendRequest}
|
||||
actionButtonText="Добавить"
|
||||
actionButtonColor="red"
|
||||
requestSending={sendingRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
92
src/components/market/organization-avatar.tsx
Normal file
92
src/components/market/organization-avatar.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
name?: string | null
|
||||
fullName?: string | null
|
||||
users?: User[]
|
||||
}
|
||||
|
||||
interface OrganizationAvatarProps {
|
||||
organization: Organization
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Цвета для fallback аватарок
|
||||
const FALLBACK_COLORS = [
|
||||
'bg-blue-500',
|
||||
'bg-green-500',
|
||||
'bg-purple-500',
|
||||
'bg-orange-500',
|
||||
'bg-pink-500',
|
||||
'bg-indigo-500',
|
||||
'bg-teal-500',
|
||||
'bg-red-500',
|
||||
'bg-yellow-500',
|
||||
'bg-cyan-500'
|
||||
]
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
function getColorForOrganization(organizationId: string): string {
|
||||
const hash = organizationId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return FALLBACK_COLORS[hash % FALLBACK_COLORS.length]
|
||||
}
|
||||
|
||||
function getSizes(size: 'sm' | 'md' | 'lg') {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return { avatar: 'size-8', text: 'text-xs' }
|
||||
case 'md':
|
||||
return { avatar: 'size-10', text: 'text-sm' }
|
||||
case 'lg':
|
||||
return { avatar: 'size-12', text: 'text-base' }
|
||||
default:
|
||||
return { avatar: 'size-8', text: 'text-xs' }
|
||||
}
|
||||
}
|
||||
|
||||
export function OrganizationAvatar({
|
||||
organization,
|
||||
size = 'md',
|
||||
className
|
||||
}: OrganizationAvatarProps) {
|
||||
// Берем аватарку первого пользователя организации
|
||||
const userAvatar = organization.users?.[0]?.avatar
|
||||
|
||||
// Получаем имя для инициалов
|
||||
const displayName = organization.name || organization.fullName || 'Организация'
|
||||
const initials = getInitials(displayName)
|
||||
|
||||
// Получаем цвет для fallback
|
||||
const fallbackColor = getColorForOrganization(organization.id)
|
||||
|
||||
const sizes = getSizes(size)
|
||||
|
||||
return (
|
||||
<Avatar className={cn(sizes.avatar, className)}>
|
||||
{userAvatar && (
|
||||
<AvatarImage src={userAvatar} alt={displayName} />
|
||||
)}
|
||||
<AvatarFallback className={cn(fallbackColor, 'text-white font-medium', sizes.text)}>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
268
src/components/market/organization-card.tsx
Normal file
268
src/components/market/organization-card.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Plus,
|
||||
Send,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { OrganizationAvatar } from './organization-avatar'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
address?: string
|
||||
phones?: Array<{ value: string }>
|
||||
emails?: Array<{ value: string }>
|
||||
createdAt: string
|
||||
users?: Array<{ id: string, avatar?: string }>
|
||||
isCounterparty?: boolean
|
||||
}
|
||||
|
||||
interface OrganizationCardProps {
|
||||
organization: Organization
|
||||
onSendRequest?: (organizationId: string, message: string) => void
|
||||
onRemove?: (organizationId: string) => void
|
||||
showRemoveButton?: boolean
|
||||
actionButtonText?: string
|
||||
actionButtonColor?: string
|
||||
requestSending?: boolean
|
||||
}
|
||||
|
||||
export function OrganizationCard({
|
||||
organization,
|
||||
onSendRequest,
|
||||
onRemove,
|
||||
showRemoveButton = false,
|
||||
actionButtonText = "Добавить",
|
||||
actionButtonColor = "green",
|
||||
requestSending = false
|
||||
}: OrganizationCardProps) {
|
||||
const [requestMessage, setRequestMessage] = useState('')
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
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 (error) {
|
||||
return 'Ошибка даты'
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'FULFILLMENT': return 'Фулфилмент'
|
||||
case 'SELLER': return 'Селлер'
|
||||
case 'LOGIST': return 'Логистика'
|
||||
case 'WHOLESALE': return 'Оптовик'
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'FULFILLMENT': return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||
case 'SELLER': return 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||
case 'LOGIST': return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
|
||||
case 'WHOLESALE': return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
|
||||
default: return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
const getActionButtonColor = (color: string, isDisabled: boolean) => {
|
||||
if (isDisabled) {
|
||||
return "bg-gray-500/20 text-gray-400 border-gray-500/30 cursor-not-allowed"
|
||||
}
|
||||
|
||||
switch (color) {
|
||||
case 'green': return 'bg-green-500/20 hover:bg-green-500/30 text-green-300 border-green-500/30'
|
||||
case 'orange': return 'bg-orange-500/20 hover:bg-orange-500/30 text-orange-300 border-orange-500/30'
|
||||
case 'yellow': return 'bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-300 border-yellow-500/30'
|
||||
case 'red': return 'bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30'
|
||||
case 'blue': return 'bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30'
|
||||
default: return 'bg-gray-500/20 hover:bg-gray-500/30 text-gray-300 border-gray-500/30'
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendRequest = () => {
|
||||
if (onSendRequest) {
|
||||
onSendRequest(organization.id, requestMessage)
|
||||
setRequestMessage('')
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
if (onRemove) {
|
||||
onRemove(organization.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div 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={organization} 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">
|
||||
{organization.name || organization.fullName}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge className={getTypeColor(organization.type)}>
|
||||
{getTypeLabel(organization.type)}
|
||||
</Badge>
|
||||
{organization.isCounterparty && (
|
||||
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
|
||||
Уже добавлен
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-white/60 text-sm">ИНН: {organization.inn}</p>
|
||||
{organization.address && (
|
||||
<div className="flex items-center text-white/60 text-sm">
|
||||
<MapPin className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{organization.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{organization.phones && organization.phones.length > 0 && (
|
||||
<div className="flex items-center text-white/60 text-sm">
|
||||
<Phone className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span>{organization.phones[0].value}</span>
|
||||
</div>
|
||||
)}
|
||||
{organization.emails && organization.emails.length > 0 && (
|
||||
<div className="flex items-center text-white/60 text-sm">
|
||||
<Mail className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="truncate">{organization.emails[0].value}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center text-white/40 text-xs">
|
||||
<Calendar className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span>{showRemoveButton ? 'Добавлен' : 'Зарегистрирован'} {formatDate(organization.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRemoveButton ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer w-full"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Удалить из контрагентов
|
||||
</Button>
|
||||
) : (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={organization.isCounterparty}
|
||||
className={`${getActionButtonColor(actionButtonColor, !!organization.isCounterparty)} w-full cursor-pointer`}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{organization.isCounterparty ? 'Уже добавлен' : actionButtonText}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="bg-gray-900/95 backdrop-blur border-white/10 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
Отправить заявку в контрагенты
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-white/5 rounded-lg border border-white/10">
|
||||
<div className="flex items-center space-x-3">
|
||||
<OrganizationAvatar organization={organization} size="sm" />
|
||||
<div>
|
||||
<h4 className="text-white font-medium">
|
||||
{organization.name || organization.fullName}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm">ИНН: {organization.inn}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Сообщение (необязательно)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Добавьте комментарий к заявке..."
|
||||
value={requestMessage}
|
||||
onChange={(e) => setRequestMessage(e.target.value)}
|
||||
className="glass-input text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
variant="outline"
|
||||
className="flex-1 bg-white/5 hover:bg-white/10 text-white border-white/20 cursor-pointer"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendRequest}
|
||||
disabled={requestSending}
|
||||
className="flex-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30 cursor-pointer"
|
||||
>
|
||||
{requestSending ? (
|
||||
"Отправка..."
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Отправить
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
431
src/components/market/organization-details-modal.tsx
Normal file
431
src/components/market/organization-details-modal.tsx
Normal file
@ -0,0 +1,431 @@
|
||||
"use client"
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Building2,
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
Calendar,
|
||||
FileText,
|
||||
Users,
|
||||
CreditCard,
|
||||
Hash,
|
||||
User,
|
||||
Briefcase
|
||||
} from 'lucide-react'
|
||||
import { OrganizationAvatar } from './organization-avatar'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
avatar?: string | null
|
||||
phone: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
marketplace: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
inn: string
|
||||
kpp?: string | null
|
||||
name?: string | null
|
||||
fullName?: string | null
|
||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
address?: string | null
|
||||
addressFull?: string | null
|
||||
ogrn?: string | null
|
||||
ogrnDate?: string | null
|
||||
status?: string | null
|
||||
actualityDate?: string | null
|
||||
registrationDate?: string | null
|
||||
liquidationDate?: string | null
|
||||
managementName?: string | null
|
||||
managementPost?: string | null
|
||||
opfCode?: string | null
|
||||
opfFull?: string | null
|
||||
opfShort?: string | null
|
||||
okato?: string | null
|
||||
oktmo?: string | null
|
||||
okpo?: string | null
|
||||
okved?: string | null
|
||||
employeeCount?: number | null
|
||||
revenue?: string | null
|
||||
taxSystem?: string | null
|
||||
phones?: Array<{ value: string }> | null
|
||||
emails?: Array<{ value: string }> | null
|
||||
users?: User[]
|
||||
apiKeys?: ApiKey[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface OrganizationDetailsModalProps {
|
||||
organization: Organization | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function formatDate(dateString?: string | null): 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 (error) {
|
||||
return 'Не указана'
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'FULFILLMENT':
|
||||
return 'Фулфилмент'
|
||||
case 'SELLER':
|
||||
return 'Селлер'
|
||||
case 'LOGIST':
|
||||
return 'Логистика'
|
||||
case 'WHOLESALE':
|
||||
return 'Оптовик'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeColor(type: string): 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'
|
||||
}
|
||||
}
|
||||
|
||||
export function OrganizationDetailsModal({ organization, open, onOpenChange }: OrganizationDetailsModalProps) {
|
||||
if (!organization) return null
|
||||
|
||||
const displayName = organization.name || organization.fullName || 'Неизвестная организация'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-black/90 backdrop-blur-xl border border-white/20">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-4 text-white">
|
||||
<OrganizationAvatar organization={organization} size="lg" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{displayName}</h2>
|
||||
<Badge className={getTypeColor(organization.type)}>
|
||||
{getTypeLabel(organization.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Основная информация */}
|
||||
<Card className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<Building2 className="h-5 w-5 mr-2 text-blue-400" />
|
||||
Основная информация
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">ИНН:</span>
|
||||
<span className="text-white font-mono">{organization.inn}</span>
|
||||
</div>
|
||||
|
||||
{organization.kpp && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">КПП:</span>
|
||||
<span className="text-white font-mono">{organization.kpp}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.ogrn && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">ОГРН:</span>
|
||||
<span className="text-white font-mono">{organization.ogrn}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.status && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Статус:</span>
|
||||
<span className="text-white">{organization.status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Дата регистрации:</span>
|
||||
<span className="text-white">{formatDate(organization.registrationDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Контактная информация */}
|
||||
<Card className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<Phone className="h-5 w-5 mr-2 text-green-400" />
|
||||
Контакты
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{organization.phones && organization.phones.length > 0 && (
|
||||
<div>
|
||||
<div className="text-white/60 text-sm mb-2">Телефоны:</div>
|
||||
{organization.phones.map((phone, index) => (
|
||||
<div key={index} className="flex items-center text-white">
|
||||
<Phone className="h-3 w-3 mr-2 text-green-400" />
|
||||
{phone.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.emails && organization.emails.length > 0 && (
|
||||
<div>
|
||||
<div className="text-white/60 text-sm mb-2">Email:</div>
|
||||
{organization.emails.map((email, index) => (
|
||||
<div key={index} className="flex items-center text-white">
|
||||
<Mail className="h-3 w-3 mr-2 text-blue-400" />
|
||||
{email.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.address && (
|
||||
<div>
|
||||
<div className="text-white/60 text-sm mb-2">Адрес:</div>
|
||||
<div className="flex items-start text-white">
|
||||
<MapPin className="h-3 w-3 mr-2 mt-1 text-orange-400 flex-shrink-0" />
|
||||
<span className="text-sm">{organization.addressFull || organization.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Руководство */}
|
||||
{organization.managementName && (
|
||||
<Card className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<User className="h-5 w-5 mr-2 text-purple-400" />
|
||||
Руководство
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Руководитель:</span>
|
||||
<span className="text-white">{organization.managementName}</span>
|
||||
</div>
|
||||
|
||||
{organization.managementPost && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Должность:</span>
|
||||
<span className="text-white">{organization.managementPost}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Организационно-правовая форма */}
|
||||
{organization.opfFull && (
|
||||
<Card className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2 text-yellow-400" />
|
||||
ОПФ
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Полное название:</span>
|
||||
<span className="text-white">{organization.opfFull}</span>
|
||||
</div>
|
||||
|
||||
{organization.opfShort && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Краткое название:</span>
|
||||
<span className="text-white">{organization.opfShort}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.opfCode && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Код ОКОПФ:</span>
|
||||
<span className="text-white font-mono">{organization.opfCode}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Коды статистики */}
|
||||
{(organization.okato || organization.oktmo || organization.okpo || organization.okved) && (
|
||||
<Card className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<Hash className="h-5 w-5 mr-2 text-cyan-400" />
|
||||
Коды статистики
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{organization.okato && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">ОКАТО:</span>
|
||||
<span className="text-white font-mono">{organization.okato}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.oktmo && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">ОКТМО:</span>
|
||||
<span className="text-white font-mono">{organization.oktmo}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.okpo && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">ОКПО:</span>
|
||||
<span className="text-white font-mono">{organization.okpo}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.okved && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Основной ОКВЭД:</span>
|
||||
<span className="text-white font-mono">{organization.okved}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Финансовая информация */}
|
||||
{(organization.employeeCount || organization.revenue || organization.taxSystem) && (
|
||||
<Card className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<CreditCard className="h-5 w-5 mr-2 text-emerald-400" />
|
||||
Финансовая информация
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{organization.employeeCount && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Сотрудников:</span>
|
||||
<span className="text-white">{organization.employeeCount}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.revenue && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Выручка:</span>
|
||||
<span className="text-white">{organization.revenue}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organization.taxSystem && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Налоговая система:</span>
|
||||
<span className="text-white">{organization.taxSystem}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Пользователи */}
|
||||
{organization.users && organization.users.length > 0 && (
|
||||
<Card className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<Users className="h-5 w-5 mr-2 text-indigo-400" />
|
||||
Пользователи ({organization.users.length})
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{organization.users.map((user, index) => (
|
||||
<div key={user.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<OrganizationAvatar
|
||||
organization={{
|
||||
id: user.id,
|
||||
users: [user]
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-white">{user.phone}</span>
|
||||
</div>
|
||||
<span className="text-white/60 text-sm">
|
||||
{formatDate(user.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* API ключи */}
|
||||
{organization.apiKeys && organization.apiKeys.length > 0 && (
|
||||
<Card className="glass-card p-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||
<Briefcase className="h-5 w-5 mr-2 text-pink-400" />
|
||||
API ключи маркетплейсов
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{organization.apiKeys.map((apiKey, index) => (
|
||||
<div key={apiKey.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge className={apiKey.isActive ? 'bg-green-500/20 text-green-300 border-green-500/30' : 'bg-red-500/20 text-red-300 border-red-500/30'}>
|
||||
{apiKey.marketplace}
|
||||
</Badge>
|
||||
<span className="text-white/60 text-sm">
|
||||
{apiKey.isActive ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-white/60 text-sm">
|
||||
{formatDate(apiKey.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user