Добавлены новые поля в модель Supply и обновлены компоненты для работы с расходниками. Реализована логика загрузки и отображения чатов с непрочитанными сообщениями в мессенджере. Обновлены запросы и мутации GraphQL для поддержки новых полей. Исправлены ошибки отображения и добавлены индикаторы для непрочитанных сообщений.
This commit is contained in:
@ -191,6 +191,13 @@ model Supply {
|
||||
description String?
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
quantity Int @default(0)
|
||||
unit String @default("шт")
|
||||
category String @default("Упаковка")
|
||||
status String @default("planned") // planned, in-transit, delivered, in-stock
|
||||
date DateTime @default(now())
|
||||
supplier String @default("Не указан")
|
||||
minStock Int @default(0)
|
||||
currentStock Int @default(0)
|
||||
imageUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { GET_CONVERSATIONS } from '@/graphql/queries'
|
||||
import {
|
||||
Settings,
|
||||
LogOut,
|
||||
@ -26,6 +28,16 @@ export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const { isCollapsed, toggleSidebar } = useSidebar()
|
||||
|
||||
// Загружаем список чатов для подсчета непрочитанных сообщений
|
||||
const { data: conversationsData } = useQuery(GET_CONVERSATIONS, {
|
||||
pollInterval: 10000, // Обновляем каждые 10 секунд
|
||||
fetchPolicy: 'cache-and-network',
|
||||
errorPolicy: 'ignore', // Игнорируем ошибки чтобы не ломать сайдбар
|
||||
})
|
||||
|
||||
const conversations = conversationsData?.conversations || []
|
||||
const totalUnreadCount = conversations.reduce((sum: number, conv: { unreadCount?: number }) => sum + (conv.unreadCount || 0), 0)
|
||||
|
||||
const getInitials = () => {
|
||||
const orgName = getOrganizationName()
|
||||
return orgName.charAt(0).toUpperCase()
|
||||
@ -221,7 +233,7 @@ export function Sidebar() {
|
||||
|
||||
<Button
|
||||
variant={isMessengerActive ? "secondary" : "ghost"}
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs relative ${
|
||||
isMessengerActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
@ -231,6 +243,16 @@ export function Sidebar() {
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Мессенджер</span>}
|
||||
{/* Индикатор непрочитанных сообщений */}
|
||||
{totalUnreadCount > 0 && (
|
||||
<div className={`absolute ${
|
||||
isCollapsed
|
||||
? 'top-1 right-1 w-3 h-3'
|
||||
: 'top-2 right-2 w-4 h-4'
|
||||
} bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-bold`}>
|
||||
{isCollapsed ? '' : (totalUnreadCount > 99 ? '99+' : totalUnreadCount)}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
@ -1,91 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Wrench, Plus, Calendar, TrendingUp, AlertCircle, Search, Filter } from 'lucide-react'
|
||||
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
||||
|
||||
interface MaterialSupply {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
quantity: number
|
||||
unit: string
|
||||
status: 'planned' | 'in-transit' | 'delivered' | 'in-stock'
|
||||
date: string
|
||||
supplier: string
|
||||
amount: number
|
||||
description?: string
|
||||
minStock: number
|
||||
currentStock: number
|
||||
price: number
|
||||
quantity: number
|
||||
unit?: string
|
||||
category?: string
|
||||
status?: string
|
||||
date: string
|
||||
supplier?: string
|
||||
minStock?: number
|
||||
currentStock?: number
|
||||
imageUrl?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const mockMaterialSupplies: MaterialSupply[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Упаковочные коробки 30x20x10',
|
||||
category: 'Упаковка',
|
||||
quantity: 1000,
|
||||
unit: 'шт',
|
||||
status: 'delivered',
|
||||
date: '2024-01-15',
|
||||
supplier: 'ООО "УпакСервис"',
|
||||
amount: 50000,
|
||||
description: 'Картонные коробки для мелких товаров',
|
||||
minStock: 200,
|
||||
currentStock: 350
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Пузырчатая пленка',
|
||||
category: 'Защитная упаковка',
|
||||
quantity: 500,
|
||||
unit: 'м²',
|
||||
status: 'in-transit',
|
||||
date: '2024-01-20',
|
||||
supplier: 'ИП Петров А.В.',
|
||||
amount: 25000,
|
||||
description: 'Пленка для защиты хрупких товаров',
|
||||
minStock: 100,
|
||||
currentStock: 80
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Скотч упаковочный прозрачный',
|
||||
category: 'Клейкая лента',
|
||||
quantity: 200,
|
||||
unit: 'рул',
|
||||
status: 'planned',
|
||||
date: '2024-01-25',
|
||||
supplier: 'ООО "КлейТех"',
|
||||
amount: 15000,
|
||||
description: 'Прозрачный скотч 48мм x 66м',
|
||||
minStock: 50,
|
||||
currentStock: 25
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Этикетки самоклеющиеся',
|
||||
category: 'Маркировка',
|
||||
quantity: 10000,
|
||||
unit: 'шт',
|
||||
status: 'in-stock',
|
||||
date: '2024-01-10',
|
||||
supplier: 'ООО "ЛейблПринт"',
|
||||
amount: 30000,
|
||||
description: 'Белые этикетки 100x70мм',
|
||||
minStock: 2000,
|
||||
currentStock: 3500
|
||||
}
|
||||
]
|
||||
|
||||
export function MaterialsSuppliesTab() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
||||
// Загружаем расходники из GraphQL
|
||||
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
||||
fetchPolicy: 'cache-and-network', // Всегда проверяем сервер
|
||||
errorPolicy: 'all' // Показываем ошибки
|
||||
})
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
@ -140,10 +92,13 @@ export function MaterialsSuppliesTab() {
|
||||
)
|
||||
}
|
||||
|
||||
const filteredSupplies = mockMaterialSupplies.filter(supply => {
|
||||
// Обрабатываем данные из GraphQL
|
||||
const supplies: MaterialSupply[] = data?.mySupplies || []
|
||||
|
||||
const filteredSupplies = supplies.filter((supply: MaterialSupply) => {
|
||||
const matchesSearch = supply.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supply.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supply.supplier.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
(supply.category || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(supply.supplier || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
const matchesCategory = categoryFilter === 'all' || supply.category === categoryFilter
|
||||
const matchesStatus = statusFilter === 'all' || supply.status === statusFilter
|
||||
@ -152,18 +107,49 @@ export function MaterialsSuppliesTab() {
|
||||
})
|
||||
|
||||
const getTotalAmount = () => {
|
||||
return filteredSupplies.reduce((sum, supply) => sum + supply.amount, 0)
|
||||
return filteredSupplies.reduce((sum: number, supply: MaterialSupply) => sum + (supply.price * supply.quantity), 0)
|
||||
}
|
||||
|
||||
const getTotalQuantity = () => {
|
||||
return filteredSupplies.reduce((sum, supply) => sum + supply.quantity, 0)
|
||||
return filteredSupplies.reduce((sum: number, supply: MaterialSupply) => sum + supply.quantity, 0)
|
||||
}
|
||||
|
||||
const getLowStockCount = () => {
|
||||
return mockMaterialSupplies.filter(supply => supply.currentStock <= supply.minStock).length
|
||||
return supplies.filter((supply: MaterialSupply) => (supply.currentStock || 0) <= (supply.minStock || 0)).length
|
||||
}
|
||||
|
||||
const categories = Array.from(new Set(mockMaterialSupplies.map(supply => supply.category)))
|
||||
const categories = Array.from(new Set(supplies.map((supply: MaterialSupply) => supply.category)))
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p className="text-white/60">Загрузка расходников...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Показываем ошибку
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<p className="text-white/60">Ошибка загрузки данных</p>
|
||||
<p className="text-white/40 text-sm mt-2">{error.message}</p>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
className="mt-4 bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
Попробовать снова
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4 p-4">
|
||||
@ -297,28 +283,28 @@ export function MaterialsSuppliesTab() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className="text-white/80 text-sm">{supply.category}</span>
|
||||
<span className="text-white/80 text-sm">{supply.category || 'Не указано'}</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className="text-white font-semibold text-sm">{supply.quantity} {supply.unit}</span>
|
||||
<span className="text-white font-semibold text-sm">{supply.quantity} {supply.unit || 'шт'}</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-white font-semibold text-sm">{supply.currentStock} {supply.unit}</span>
|
||||
{getStockStatusBadge(supply.currentStock, supply.minStock)}
|
||||
<span className="text-white font-semibold text-sm">{supply.currentStock || 0} {supply.unit || 'шт'}</span>
|
||||
{getStockStatusBadge(supply.currentStock || 0, supply.minStock || 0)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className="text-white/80 text-sm">{supply.supplier}</span>
|
||||
<span className="text-white/80 text-sm">{supply.supplier || 'Не указан'}</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className="text-white/80 text-sm">{formatDate(supply.date)}</span>
|
||||
<span className="text-white/80 text-sm">{supply.date ? formatDate(supply.date) : 'Не указано'}</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className="text-white font-semibold text-sm">{formatCurrency(supply.amount)}</span>
|
||||
<span className="text-white font-semibold text-sm">{formatCurrency(supply.price * supply.quantity)}</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{getStatusBadge(supply.status)}
|
||||
{getStatusBadge(supply.status || 'planned')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { GET_MESSAGES } from '@/graphql/queries'
|
||||
import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE } from '@/graphql/mutations'
|
||||
import { SEND_MESSAGE, SEND_VOICE_MESSAGE, SEND_IMAGE_MESSAGE, SEND_FILE_MESSAGE, MARK_MESSAGES_AS_READ } from '@/graphql/mutations'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
@ -61,6 +61,13 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
|
||||
errorPolicy: 'all' // Показываем данные даже при ошибках
|
||||
})
|
||||
|
||||
// Мутация для отметки сообщений как прочитанных
|
||||
const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, {
|
||||
onError: (error) => {
|
||||
console.error('Ошибка отметки сообщений как прочитанных:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
|
||||
onCompleted: () => {
|
||||
refetch() // Перезагружаем сообщения после отправки
|
||||
@ -99,6 +106,33 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
|
||||
|
||||
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages])
|
||||
|
||||
// Автоматически отмечаем сообщения как прочитанные при открытии чата
|
||||
useEffect(() => {
|
||||
if (user?.organization?.id && counterparty.id) {
|
||||
const conversationId = `${user.organization.id}-${counterparty.id}`
|
||||
markMessagesAsReadMutation({
|
||||
variables: { conversationId },
|
||||
})
|
||||
}
|
||||
}, [counterparty.id, user?.organization?.id, markMessagesAsReadMutation])
|
||||
|
||||
// Отмечаем сообщения как прочитанные при получении новых сообщений
|
||||
useEffect(() => {
|
||||
if (messages.length > 0 && user?.organization?.id && counterparty.id) {
|
||||
const hasUnreadMessages = messages.some((msg: Message) =>
|
||||
!msg.isRead &&
|
||||
msg.senderOrganization?.id !== user.organization?.id
|
||||
)
|
||||
|
||||
if (hasUnreadMessages) {
|
||||
const conversationId = `${user.organization.id}-${counterparty.id}`
|
||||
markMessagesAsReadMutation({
|
||||
variables: { conversationId },
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [messages, counterparty.id, user?.organization?.id, markMessagesAsReadMutation])
|
||||
|
||||
|
||||
|
||||
const scrollToBottom = () => {
|
||||
|
@ -20,19 +20,38 @@ interface Organization {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string
|
||||
counterparty: Organization
|
||||
lastMessage?: {
|
||||
id: string
|
||||
content?: string
|
||||
type?: string
|
||||
senderId: string
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
unreadCount: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface MessengerConversationsProps {
|
||||
conversations?: Conversation[]
|
||||
counterparties: Organization[]
|
||||
loading: boolean
|
||||
selectedCounterparty: string | null
|
||||
onSelectCounterparty: (counterpartyId: string) => void
|
||||
onRefresh?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function MessengerConversations({
|
||||
conversations = [],
|
||||
counterparties,
|
||||
loading,
|
||||
selectedCounterparty,
|
||||
onSelectCounterparty,
|
||||
onRefresh,
|
||||
compact = false
|
||||
}: MessengerConversationsProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@ -111,13 +130,39 @@ export function MessengerConversations({
|
||||
}
|
||||
}
|
||||
|
||||
const filteredCounterparties = counterparties.filter(org => {
|
||||
// Объединяем чаты и контрагентов, приоритет у чатов
|
||||
const allCounterparties = new Map<string, { org: Organization; conversation?: Conversation }>()
|
||||
|
||||
// Сначала добавляем из чатов
|
||||
conversations.forEach(conv => {
|
||||
allCounterparties.set(conv.counterparty.id, {
|
||||
org: conv.counterparty,
|
||||
conversation: conv
|
||||
})
|
||||
})
|
||||
|
||||
// Затем добавляем остальных контрагентов, если их еще нет
|
||||
counterparties.forEach(org => {
|
||||
if (!allCounterparties.has(org.id)) {
|
||||
allCounterparties.set(org.id, { org })
|
||||
}
|
||||
})
|
||||
|
||||
const filteredCounterparties = Array.from(allCounterparties.values()).filter(({ org }) => {
|
||||
if (!searchTerm) return true
|
||||
const name = getOrganizationName(org).toLowerCase()
|
||||
const managerName = getManagerName(org).toLowerCase()
|
||||
const inn = org.inn.toLowerCase()
|
||||
const search = searchTerm.toLowerCase()
|
||||
return name.includes(search) || inn.includes(search) || managerName.includes(search)
|
||||
}).sort((a, b) => {
|
||||
// Сортируем: сначала с активными чатами, потом по времени последнего сообщения
|
||||
if (a.conversation && !b.conversation) return -1
|
||||
if (!a.conversation && b.conversation) return 1
|
||||
if (a.conversation && b.conversation) {
|
||||
return new Date(b.conversation.updatedAt).getTime() - new Date(a.conversation.updatedAt).getTime()
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
@ -136,7 +181,9 @@ export function MessengerConversations({
|
||||
<Users className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
|
||||
<p className="text-white/60 text-sm">{counterparties.length} активных</p>
|
||||
<p className="text-white/60 text-sm">
|
||||
{conversations.length > 0 ? `${conversations.length} активных чатов` : `${counterparties.length} контрагентов`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -145,7 +192,7 @@ export function MessengerConversations({
|
||||
{compact && (
|
||||
<div className="flex items-center justify-center mb-3">
|
||||
<Users className="h-4 w-4 text-blue-400 mr-2" />
|
||||
<span className="text-white font-medium text-sm">{counterparties.length}</span>
|
||||
<span className="text-white font-medium text-sm">{allCounterparties.size}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -172,11 +219,17 @@ export function MessengerConversations({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredCounterparties.map((org) => (
|
||||
filteredCounterparties.map(({ org, conversation }) => (
|
||||
<div
|
||||
key={org.id}
|
||||
onClick={() => onSelectCounterparty(org.id)}
|
||||
className={`${compact ? 'p-2' : 'p-3'} rounded-lg cursor-pointer transition-all duration-200 ${
|
||||
onClick={() => {
|
||||
onSelectCounterparty(org.id)
|
||||
// Если есть непрочитанные сообщения и функция обновления, вызываем её
|
||||
if (conversation?.unreadCount && conversation.unreadCount > 0 && onRefresh) {
|
||||
setTimeout(() => onRefresh(), 100) // Небольшая задержка для UI
|
||||
}
|
||||
}}
|
||||
className={`${compact ? 'p-2' : 'p-3'} rounded-lg cursor-pointer transition-all duration-200 relative ${
|
||||
selectedCounterparty === org.id
|
||||
? 'bg-white/20 border border-white/30'
|
||||
: 'bg-white/5 hover:bg-white/10 border border-white/10'
|
||||
@ -184,7 +237,7 @@ export function MessengerConversations({
|
||||
>
|
||||
{compact ? (
|
||||
/* Компактный режим */
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center justify-center relative">
|
||||
<Avatar className="h-8 w-8">
|
||||
{org.users?.[0]?.avatar ? (
|
||||
<AvatarImage
|
||||
@ -197,10 +250,17 @@ export function MessengerConversations({
|
||||
{getInitials(org)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* Индикатор непрочитанных сообщений для компактного режима */}
|
||||
{conversation?.unreadCount && conversation.unreadCount > 0 && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-bold">
|
||||
{conversation.unreadCount > 9 ? '9+' : conversation.unreadCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Обычный режим */
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 flex-shrink-0">
|
||||
{org.users?.[0]?.avatar ? (
|
||||
<AvatarImage
|
||||
@ -213,20 +273,57 @@ export function MessengerConversations({
|
||||
{getInitials(org)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* Индикатор непрочитанных сообщений */}
|
||||
{conversation?.unreadCount && conversation.unreadCount > 0 && (
|
||||
<div className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center font-bold">
|
||||
{conversation.unreadCount > 9 ? '9+' : conversation.unreadCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h4 className="text-white font-medium text-sm leading-tight truncate">
|
||||
<h4 className={`font-medium text-sm leading-tight truncate ${
|
||||
conversation?.unreadCount && conversation.unreadCount > 0
|
||||
? 'text-white'
|
||||
: 'text-white/80'
|
||||
}`}>
|
||||
{getOrganizationName(org)}
|
||||
</h4>
|
||||
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0 ml-2`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{conversation?.unreadCount && conversation.unreadCount > 0 && (
|
||||
<Badge className="bg-red-500/20 text-red-300 border-red-500/30 text-xs">
|
||||
{conversation.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0`}>
|
||||
{getTypeLabel(org.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conversation?.lastMessage ? (
|
||||
<p className={`text-xs truncate ${
|
||||
conversation.unreadCount && conversation.unreadCount > 0
|
||||
? 'text-white/80'
|
||||
: 'text-white/60'
|
||||
}`}>
|
||||
{conversation.lastMessage.type === 'TEXT'
|
||||
? conversation.lastMessage.content
|
||||
: conversation.lastMessage.type === 'VOICE'
|
||||
? '🎵 Голосовое сообщение'
|
||||
: conversation.lastMessage.type === 'IMAGE'
|
||||
? '🖼️ Изображение'
|
||||
: conversation.lastMessage.type === 'FILE'
|
||||
? '📄 Файл'
|
||||
: 'Сообщение'
|
||||
}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-white/60 text-xs truncate">
|
||||
{getShortCompanyName(org.fullName || '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -9,7 +9,7 @@ import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { MessengerConversations } from './messenger-conversations'
|
||||
import { MessengerChat } from './messenger-chat'
|
||||
import { MessengerEmptyState } from './messenger-empty-state'
|
||||
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||
import { GET_CONVERSATIONS, GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
|
||||
@ -26,21 +26,47 @@ interface Organization {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string
|
||||
counterparty: Organization
|
||||
lastMessage?: {
|
||||
id: string
|
||||
content?: string
|
||||
type?: string
|
||||
senderId: string
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
unreadCount: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function MessengerDashboard() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
|
||||
|
||||
// Загружаем список чатов (conversations) для отображения непрочитанных сообщений
|
||||
const { data: conversationsData, loading: conversationsLoading, refetch: refetchConversations } = useQuery(GET_CONVERSATIONS, {
|
||||
pollInterval: 5000, // Обновляем каждые 5 секунд для получения новых непрочитанных сообщений
|
||||
fetchPolicy: 'cache-and-network',
|
||||
})
|
||||
|
||||
// Также загружаем полный список контрагентов на случай, если с кем-то еще не общались
|
||||
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
||||
|
||||
const conversations: Conversation[] = conversationsData?.conversations || []
|
||||
const counterparties = counterpartiesData?.myCounterparties || []
|
||||
|
||||
const handleSelectCounterparty = (counterpartyId: string) => {
|
||||
setSelectedCounterparty(counterpartyId)
|
||||
}
|
||||
|
||||
const selectedCounterpartyData = counterparties.find((cp: Organization) => cp.id === selectedCounterparty)
|
||||
// Найти данные выбранного контрагента (сначала в чатах, потом в общем списке)
|
||||
const selectedCounterpartyData = conversations.find((conv: Conversation) => conv.counterparty.id === selectedCounterparty)?.counterparty ||
|
||||
counterparties.find((cp: Organization) => cp.id === selectedCounterparty)
|
||||
|
||||
// Если нет контрагентов, показываем заглушку
|
||||
if (!counterpartiesLoading && counterparties.length === 0) {
|
||||
if (!counterpartiesLoading && !conversationsLoading && counterparties.length === 0) {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
@ -74,10 +100,12 @@ export function MessengerDashboard() {
|
||||
>
|
||||
<Card className="glass-card h-full overflow-hidden p-4">
|
||||
<MessengerConversations
|
||||
conversations={conversations}
|
||||
counterparties={counterparties}
|
||||
loading={counterpartiesLoading}
|
||||
loading={counterpartiesLoading || conversationsLoading}
|
||||
selectedCounterparty={selectedCounterparty}
|
||||
onSelectCounterparty={handleSelectCounterparty}
|
||||
onRefresh={refetchConversations}
|
||||
compact={false}
|
||||
/>
|
||||
</Card>
|
||||
|
@ -603,6 +603,14 @@ export const CREATE_SUPPLY = gql`
|
||||
name
|
||||
description
|
||||
price
|
||||
quantity
|
||||
unit
|
||||
category
|
||||
status
|
||||
date
|
||||
supplier
|
||||
minStock
|
||||
currentStock
|
||||
imageUrl
|
||||
createdAt
|
||||
updatedAt
|
||||
@ -621,6 +629,14 @@ export const UPDATE_SUPPLY = gql`
|
||||
name
|
||||
description
|
||||
price
|
||||
quantity
|
||||
unit
|
||||
category
|
||||
status
|
||||
date
|
||||
supplier
|
||||
minStock
|
||||
currentStock
|
||||
imageUrl
|
||||
createdAt
|
||||
updatedAt
|
||||
|
@ -72,6 +72,14 @@ export const GET_MY_SUPPLIES = gql`
|
||||
name
|
||||
description
|
||||
price
|
||||
quantity
|
||||
unit
|
||||
category
|
||||
status
|
||||
date
|
||||
supplier
|
||||
minStock
|
||||
currentStock
|
||||
imageUrl
|
||||
createdAt
|
||||
updatedAt
|
||||
|
@ -533,9 +533,81 @@ export const resolvers = {
|
||||
throw new GraphQLError("У пользователя нет организации");
|
||||
}
|
||||
|
||||
// TODO: Здесь будет логика получения списка чатов
|
||||
// Пока возвращаем пустой массив, так как таблица сообщений еще не создана
|
||||
return [];
|
||||
// Получаем всех контрагентов
|
||||
const counterparties = await prisma.counterparty.findMany({
|
||||
where: { organizationId: currentUser.organization.id },
|
||||
include: {
|
||||
counterparty: {
|
||||
include: {
|
||||
users: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Для каждого контрагента получаем последнее сообщение и количество непрочитанных
|
||||
const conversations = await Promise.all(
|
||||
counterparties.map(async (cp) => {
|
||||
const counterpartyId = cp.counterparty.id;
|
||||
|
||||
// Последнее сообщение с этим контрагентом
|
||||
const lastMessage = await prisma.message.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
senderOrganizationId: currentUser.organization!.id,
|
||||
receiverOrganizationId: counterpartyId,
|
||||
},
|
||||
{
|
||||
senderOrganizationId: counterpartyId,
|
||||
receiverOrganizationId: currentUser.organization!.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
sender: true,
|
||||
senderOrganization: {
|
||||
include: {
|
||||
users: true,
|
||||
},
|
||||
},
|
||||
receiverOrganization: {
|
||||
include: {
|
||||
users: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
// Количество непрочитанных сообщений от этого контрагента
|
||||
const unreadCount = await prisma.message.count({
|
||||
where: {
|
||||
senderOrganizationId: counterpartyId,
|
||||
receiverOrganizationId: currentUser.organization!.id,
|
||||
isRead: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Если есть сообщения с этим контрагентом, включаем его в список
|
||||
if (lastMessage) {
|
||||
return {
|
||||
id: `${currentUser.organization!.id}-${counterpartyId}`,
|
||||
counterparty: cp.counterparty,
|
||||
lastMessage,
|
||||
unreadCount,
|
||||
updatedAt: lastMessage.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
// Фильтруем null значения и сортируем по времени последнего сообщения
|
||||
return conversations
|
||||
.filter((conv) => conv !== null)
|
||||
.sort((a, b) => new Date(b!.updatedAt).getTime() - new Date(a!.updatedAt).getTime());
|
||||
},
|
||||
|
||||
// Мои услуги
|
||||
@ -2410,8 +2482,34 @@ export const resolvers = {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Здесь будет логика обновления статуса сообщений
|
||||
// Пока возвращаем успешный ответ
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: { organization: true },
|
||||
});
|
||||
|
||||
if (!currentUser?.organization) {
|
||||
throw new GraphQLError("У пользователя нет организации");
|
||||
}
|
||||
|
||||
// conversationId имеет формат "currentOrgId-counterpartyId"
|
||||
const [, counterpartyId] = args.conversationId.split('-');
|
||||
|
||||
if (!counterpartyId) {
|
||||
throw new GraphQLError("Неверный ID беседы");
|
||||
}
|
||||
|
||||
// Помечаем все непрочитанные сообщения от контрагента как прочитанные
|
||||
await prisma.message.updateMany({
|
||||
where: {
|
||||
senderOrganizationId: counterpartyId,
|
||||
receiverOrganizationId: currentUser.organization.id,
|
||||
isRead: false,
|
||||
},
|
||||
data: {
|
||||
isRead: true,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
@ -2594,6 +2692,14 @@ export const resolvers = {
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
category: string;
|
||||
status: string;
|
||||
date: string;
|
||||
supplier: string;
|
||||
minStock: number;
|
||||
currentStock: number;
|
||||
imageUrl?: string;
|
||||
};
|
||||
},
|
||||
@ -2627,7 +2733,14 @@ export const resolvers = {
|
||||
name: args.input.name,
|
||||
description: args.input.description,
|
||||
price: args.input.price,
|
||||
quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса
|
||||
quantity: args.input.quantity,
|
||||
unit: args.input.unit,
|
||||
category: args.input.category,
|
||||
status: args.input.status,
|
||||
date: new Date(args.input.date),
|
||||
supplier: args.input.supplier,
|
||||
minStock: args.input.minStock,
|
||||
currentStock: args.input.currentStock,
|
||||
imageUrl: args.input.imageUrl,
|
||||
organizationId: currentUser.organization.id,
|
||||
},
|
||||
@ -2657,6 +2770,14 @@ export const resolvers = {
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
category: string;
|
||||
status: string;
|
||||
date: string;
|
||||
supplier: string;
|
||||
minStock: number;
|
||||
currentStock: number;
|
||||
imageUrl?: string;
|
||||
};
|
||||
},
|
||||
@ -2696,7 +2817,14 @@ export const resolvers = {
|
||||
name: args.input.name,
|
||||
description: args.input.description,
|
||||
price: args.input.price,
|
||||
quantity: 0, // Временно устанавливаем 0, так как поле убрано из интерфейса
|
||||
quantity: args.input.quantity,
|
||||
unit: args.input.unit,
|
||||
category: args.input.category,
|
||||
status: args.input.status,
|
||||
date: new Date(args.input.date),
|
||||
supplier: args.input.supplier,
|
||||
minStock: args.input.minStock,
|
||||
currentStock: args.input.currentStock,
|
||||
imageUrl: args.input.imageUrl,
|
||||
},
|
||||
include: { organization: true },
|
||||
@ -2912,9 +3040,37 @@ export const resolvers = {
|
||||
},
|
||||
});
|
||||
|
||||
// Создаем расходники на основе заказанных товаров
|
||||
const suppliesData = args.input.items.map((item) => {
|
||||
const product = products.find((p) => p.id === item.productId)!;
|
||||
const productWithCategory = supplyOrder.items.find(
|
||||
(orderItem) => orderItem.productId === item.productId
|
||||
)?.product;
|
||||
|
||||
return {
|
||||
name: product.name,
|
||||
description: product.description || `Заказано у ${partner.name}`,
|
||||
price: product.price,
|
||||
quantity: item.quantity,
|
||||
unit: "шт",
|
||||
category: productWithCategory?.category?.name || "Упаковка",
|
||||
status: "in-transit", // Статус "в пути" так как заказ только создан
|
||||
date: new Date(args.input.deliveryDate),
|
||||
supplier: partner.name || partner.fullName || "Не указан",
|
||||
minStock: Math.round(item.quantity * 0.1), // 10% от заказанного как минимальный остаток
|
||||
currentStock: 0, // Пока товар не пришел
|
||||
organizationId: currentUser.organization!.id,
|
||||
};
|
||||
});
|
||||
|
||||
// Создаем расходники
|
||||
await prisma.supply.createMany({
|
||||
data: suppliesData,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Заказ поставки создан успешно",
|
||||
message: `Заказ поставки создан успешно! Добавлено ${suppliesData.length} расходников в каталог.`,
|
||||
order: supplyOrder,
|
||||
};
|
||||
} catch (error) {
|
||||
@ -3000,7 +3156,7 @@ export const resolvers = {
|
||||
weight: args.input.weight,
|
||||
dimensions: args.input.dimensions,
|
||||
material: args.input.material,
|
||||
images: args.input.images || [],
|
||||
images: JSON.stringify(args.input.images || []),
|
||||
mainImage: args.input.mainImage,
|
||||
isActive: args.input.isActive ?? true,
|
||||
organizationId: currentUser.organization.id,
|
||||
@ -3111,7 +3267,7 @@ export const resolvers = {
|
||||
weight: args.input.weight,
|
||||
dimensions: args.input.dimensions,
|
||||
material: args.input.material,
|
||||
images: args.input.images || [],
|
||||
images: args.input.images ? JSON.stringify(args.input.images) : undefined,
|
||||
mainImage: args.input.mainImage,
|
||||
isActive: args.input.isActive ?? true,
|
||||
},
|
||||
@ -4213,6 +4369,25 @@ export const resolvers = {
|
||||
},
|
||||
},
|
||||
|
||||
Product: {
|
||||
images: (parent: { images: unknown }) => {
|
||||
// Если images это строка JSON, парсим её в массив
|
||||
if (typeof parent.images === 'string') {
|
||||
try {
|
||||
return JSON.parse(parent.images);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// Если это уже массив, возвращаем как есть
|
||||
if (Array.isArray(parent.images)) {
|
||||
return parent.images;
|
||||
}
|
||||
// Иначе возвращаем пустой массив
|
||||
return [];
|
||||
},
|
||||
},
|
||||
|
||||
Message: {
|
||||
type: (parent: { type?: string | null }) => {
|
||||
return parent.type || "TEXT";
|
||||
|
@ -444,6 +444,14 @@ export const typeDefs = gql`
|
||||
name: String!
|
||||
description: String
|
||||
price: Float!
|
||||
quantity: Int!
|
||||
unit: String
|
||||
category: String
|
||||
status: String
|
||||
date: DateTime!
|
||||
supplier: String
|
||||
minStock: Int
|
||||
currentStock: Int
|
||||
imageUrl: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
@ -454,6 +462,14 @@ export const typeDefs = gql`
|
||||
name: String!
|
||||
description: String
|
||||
price: Float!
|
||||
quantity: Int!
|
||||
unit: String!
|
||||
category: String!
|
||||
status: String!
|
||||
date: DateTime!
|
||||
supplier: String!
|
||||
minStock: Int!
|
||||
currentStock: Int!
|
||||
imageUrl: String
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user