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

This commit is contained in:
Bivekich
2025-07-21 14:34:12 +03:00
parent f71262577a
commit a3fc7d969f
10 changed files with 514 additions and 125 deletions

View File

@ -191,6 +191,13 @@ model Supply {
description String? description String?
price Decimal @db.Decimal(10, 2) price Decimal @db.Decimal(10, 2)
quantity Int @default(0) 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? imageUrl String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import { useQuery } from '@apollo/client'
import { GET_CONVERSATIONS } from '@/graphql/queries'
import { import {
Settings, Settings,
LogOut, LogOut,
@ -26,6 +28,16 @@ export function Sidebar() {
const pathname = usePathname() const pathname = usePathname()
const { isCollapsed, toggleSidebar } = useSidebar() 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 getInitials = () => {
const orgName = getOrganizationName() const orgName = getOrganizationName()
return orgName.charAt(0).toUpperCase() return orgName.charAt(0).toUpperCase()
@ -221,7 +233,7 @@ export function Sidebar() {
<Button <Button
variant={isMessengerActive ? "secondary" : "ghost"} 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 isMessengerActive
? 'bg-white/20 text-white hover:bg-white/30' ? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/80 hover:bg-white/10 hover:text-white' : '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" /> <MessageCircle className="h-4 w-4 flex-shrink-0" />
{!isCollapsed && <span className="ml-3">Мессенджер</span>} {!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>
<Button <Button

View File

@ -1,91 +1,43 @@
"use client" "use client"
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@apollo/client'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Wrench, Plus, Calendar, TrendingUp, AlertCircle, Search, Filter } from 'lucide-react' import { Wrench, Plus, Calendar, TrendingUp, AlertCircle, Search, Filter } from 'lucide-react'
import { GET_MY_SUPPLIES } from '@/graphql/queries'
interface MaterialSupply { interface MaterialSupply {
id: string id: string
name: string name: string
category: string
quantity: number
unit: string
status: 'planned' | 'in-transit' | 'delivered' | 'in-stock'
date: string
supplier: string
amount: number
description?: string description?: string
minStock: number price: number
currentStock: 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() { export function MaterialsSuppliesTab() {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [categoryFilter, setCategoryFilter] = useState<string>('all') const [categoryFilter, setCategoryFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = 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) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {
style: 'currency', 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()) || const matchesSearch = supply.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
supply.category.toLowerCase().includes(searchTerm.toLowerCase()) || (supply.category || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
supply.supplier.toLowerCase().includes(searchTerm.toLowerCase()) (supply.supplier || '').toLowerCase().includes(searchTerm.toLowerCase())
const matchesCategory = categoryFilter === 'all' || supply.category === categoryFilter const matchesCategory = categoryFilter === 'all' || supply.category === categoryFilter
const matchesStatus = statusFilter === 'all' || supply.status === statusFilter const matchesStatus = statusFilter === 'all' || supply.status === statusFilter
@ -152,18 +107,49 @@ export function MaterialsSuppliesTab() {
}) })
const getTotalAmount = () => { 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 = () => { const getTotalQuantity = () => {
return filteredSupplies.reduce((sum, supply) => sum + supply.quantity, 0) return filteredSupplies.reduce((sum: number, supply: MaterialSupply) => sum + supply.quantity, 0)
} }
const getLowStockCount = () => { 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 ( return (
<div className="h-full flex flex-col space-y-4 p-4"> <div className="h-full flex flex-col space-y-4 p-4">
@ -297,28 +283,28 @@ export function MaterialsSuppliesTab() {
</div> </div>
</td> </td>
<td className="p-2"> <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>
<td className="p-2"> <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>
<td className="p-2"> <td className="p-2">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-white font-semibold text-sm">{supply.currentStock} {supply.unit}</span> <span className="text-white font-semibold text-sm">{supply.currentStock || 0} {supply.unit || 'шт'}</span>
{getStockStatusBadge(supply.currentStock, supply.minStock)} {getStockStatusBadge(supply.currentStock || 0, supply.minStock || 0)}
</div> </div>
</td> </td>
<td className="p-2"> <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>
<td className="p-2"> <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>
<td className="p-2"> <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>
<td className="p-2"> <td className="p-2">
{getStatusBadge(supply.status)} {getStatusBadge(supply.status || 'planned')}
</td> </td>
</tr> </tr>
))} ))}

View File

@ -3,7 +3,7 @@
import { useState, useRef, useEffect, useMemo } from 'react' import { useState, useRef, useEffect, useMemo } from 'react'
import { useMutation, useQuery } from '@apollo/client' import { useMutation, useQuery } from '@apollo/client'
import { GET_MESSAGES } from '@/graphql/queries' 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 { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
@ -60,6 +60,13 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные fetchPolicy: 'cache-and-network', // Всегда загружаем свежие данные
errorPolicy: 'all' // Показываем данные даже при ошибках errorPolicy: 'all' // Показываем данные даже при ошибках
}) })
// Мутация для отметки сообщений как прочитанных
const [markMessagesAsReadMutation] = useMutation(MARK_MESSAGES_AS_READ, {
onError: (error) => {
console.error('Ошибка отметки сообщений как прочитанных:', error)
}
})
const [sendMessageMutation] = useMutation(SEND_MESSAGE, { const [sendMessageMutation] = useMutation(SEND_MESSAGE, {
onCompleted: () => { onCompleted: () => {
@ -99,6 +106,33 @@ export function MessengerChat({ counterparty }: MessengerChatProps) {
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages]) 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 = () => { const scrollToBottom = () => {

View File

@ -20,19 +20,38 @@ interface Organization {
createdAt: string 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 { interface MessengerConversationsProps {
conversations?: Conversation[]
counterparties: Organization[] counterparties: Organization[]
loading: boolean loading: boolean
selectedCounterparty: string | null selectedCounterparty: string | null
onSelectCounterparty: (counterpartyId: string) => void onSelectCounterparty: (counterpartyId: string) => void
onRefresh?: () => void
compact?: boolean compact?: boolean
} }
export function MessengerConversations({ export function MessengerConversations({
conversations = [],
counterparties, counterparties,
loading, loading,
selectedCounterparty, selectedCounterparty,
onSelectCounterparty, onSelectCounterparty,
onRefresh,
compact = false compact = false
}: MessengerConversationsProps) { }: MessengerConversationsProps) {
const [searchTerm, setSearchTerm] = useState('') 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 if (!searchTerm) return true
const name = getOrganizationName(org).toLowerCase() const name = getOrganizationName(org).toLowerCase()
const managerName = getManagerName(org).toLowerCase() const managerName = getManagerName(org).toLowerCase()
const inn = org.inn.toLowerCase() const inn = org.inn.toLowerCase()
const search = searchTerm.toLowerCase() const search = searchTerm.toLowerCase()
return name.includes(search) || inn.includes(search) || managerName.includes(search) 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) { if (loading) {
@ -136,7 +181,9 @@ export function MessengerConversations({
<Users className="h-5 w-5 text-blue-400" /> <Users className="h-5 w-5 text-blue-400" />
<div> <div>
<h3 className="text-lg font-semibold text-white">Контрагенты</h3> <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>
</div> </div>
)} )}
@ -145,7 +192,7 @@ export function MessengerConversations({
{compact && ( {compact && (
<div className="flex items-center justify-center mb-3"> <div className="flex items-center justify-center mb-3">
<Users className="h-4 w-4 text-blue-400 mr-2" /> <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> </div>
)} )}
@ -172,11 +219,17 @@ export function MessengerConversations({
</p> </p>
</div> </div>
) : ( ) : (
filteredCounterparties.map((org) => ( filteredCounterparties.map(({ org, conversation }) => (
<div <div
key={org.id} key={org.id}
onClick={() => onSelectCounterparty(org.id)} onClick={() => {
className={`${compact ? 'p-2' : 'p-3'} rounded-lg cursor-pointer transition-all duration-200 ${ 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 selectedCounterparty === org.id
? 'bg-white/20 border border-white/30' ? 'bg-white/20 border border-white/30'
: 'bg-white/5 hover:bg-white/10 border border-white/10' : 'bg-white/5 hover:bg-white/10 border border-white/10'
@ -184,7 +237,7 @@ export function MessengerConversations({
> >
{compact ? ( {compact ? (
/* Компактный режим */ /* Компактный режим */
<div className="flex items-center justify-center"> <div className="flex items-center justify-center relative">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
{org.users?.[0]?.avatar ? ( {org.users?.[0]?.avatar ? (
<AvatarImage <AvatarImage
@ -197,36 +250,80 @@ export function MessengerConversations({
{getInitials(org)} {getInitials(org)}
</AvatarFallback> </AvatarFallback>
</Avatar> </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>
) : ( ) : (
/* Обычный режим */ /* Обычный режим */
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Avatar className="h-10 w-10 flex-shrink-0"> <div className="relative">
{org.users?.[0]?.avatar ? ( <Avatar className="h-10 w-10 flex-shrink-0">
<AvatarImage {org.users?.[0]?.avatar ? (
src={org.users[0].avatar} <AvatarImage
alt="Аватар организации" src={org.users[0].avatar}
className="w-full h-full object-cover" alt="Аватар организации"
/> className="w-full h-full object-cover"
) : null} />
<AvatarFallback className="bg-purple-500 text-white text-sm"> ) : null}
{getInitials(org)} <AvatarFallback className="bg-purple-500 text-white text-sm">
</AvatarFallback> {getInitials(org)}
</Avatar> </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-1 min-w-0">
<div className="flex items-center justify-between mb-1"> <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)} {getOrganizationName(org)}
</h4> </h4>
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0 ml-2`}> <div className="flex items-center space-x-2">
{getTypeLabel(org.type)} {conversation?.unreadCount && conversation.unreadCount > 0 && (
</Badge> <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> </div>
<p className="text-white/60 text-xs truncate"> {conversation?.lastMessage ? (
{getShortCompanyName(org.fullName || '')} <p className={`text-xs truncate ${
</p> 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>
</div> </div>
)} )}

View File

@ -9,7 +9,7 @@ import { useSidebar } from '@/hooks/useSidebar'
import { MessengerConversations } from './messenger-conversations' import { MessengerConversations } from './messenger-conversations'
import { MessengerChat } from './messenger-chat' import { MessengerChat } from './messenger-chat'
import { MessengerEmptyState } from './messenger-empty-state' 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 { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
@ -26,21 +26,47 @@ interface Organization {
createdAt: string 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() { export function MessengerDashboard() {
const { getSidebarMargin } = useSidebar() const { getSidebarMargin } = useSidebar()
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null) 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 { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
const conversations: Conversation[] = conversationsData?.conversations || []
const counterparties = counterpartiesData?.myCounterparties || [] const counterparties = counterpartiesData?.myCounterparties || []
const handleSelectCounterparty = (counterpartyId: string) => { const handleSelectCounterparty = (counterpartyId: string) => {
setSelectedCounterparty(counterpartyId) 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 ( return (
<div className="h-screen flex overflow-hidden"> <div className="h-screen flex overflow-hidden">
<Sidebar /> <Sidebar />
@ -74,10 +100,12 @@ export function MessengerDashboard() {
> >
<Card className="glass-card h-full overflow-hidden p-4"> <Card className="glass-card h-full overflow-hidden p-4">
<MessengerConversations <MessengerConversations
conversations={conversations}
counterparties={counterparties} counterparties={counterparties}
loading={counterpartiesLoading} loading={counterpartiesLoading || conversationsLoading}
selectedCounterparty={selectedCounterparty} selectedCounterparty={selectedCounterparty}
onSelectCounterparty={handleSelectCounterparty} onSelectCounterparty={handleSelectCounterparty}
onRefresh={refetchConversations}
compact={false} compact={false}
/> />
</Card> </Card>

View File

@ -603,6 +603,14 @@ export const CREATE_SUPPLY = gql`
name name
description description
price price
quantity
unit
category
status
date
supplier
minStock
currentStock
imageUrl imageUrl
createdAt createdAt
updatedAt updatedAt
@ -621,6 +629,14 @@ export const UPDATE_SUPPLY = gql`
name name
description description
price price
quantity
unit
category
status
date
supplier
minStock
currentStock
imageUrl imageUrl
createdAt createdAt
updatedAt updatedAt

View File

@ -72,6 +72,14 @@ export const GET_MY_SUPPLIES = gql`
name name
description description
price price
quantity
unit
category
status
date
supplier
minStock
currentStock
imageUrl imageUrl
createdAt createdAt
updatedAt updatedAt

View File

@ -533,9 +533,81 @@ export const resolvers = {
throw new GraphQLError("У пользователя нет организации"); throw new GraphQLError("У пользователя нет организации");
} }
// TODO: Здесь будет логика получения списка чатов // Получаем всех контрагентов
// Пока возвращаем пустой массив, так как таблица сообщений еще не создана const counterparties = await prisma.counterparty.findMany({
return []; 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; return true;
}, },
@ -2594,6 +2692,14 @@ export const resolvers = {
name: string; name: string;
description?: string; description?: string;
price: number; price: number;
quantity: number;
unit: string;
category: string;
status: string;
date: string;
supplier: string;
minStock: number;
currentStock: number;
imageUrl?: string; imageUrl?: string;
}; };
}, },
@ -2627,7 +2733,14 @@ export const resolvers = {
name: args.input.name, name: args.input.name,
description: args.input.description, description: args.input.description,
price: args.input.price, 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, imageUrl: args.input.imageUrl,
organizationId: currentUser.organization.id, organizationId: currentUser.organization.id,
}, },
@ -2657,6 +2770,14 @@ export const resolvers = {
name: string; name: string;
description?: string; description?: string;
price: number; price: number;
quantity: number;
unit: string;
category: string;
status: string;
date: string;
supplier: string;
minStock: number;
currentStock: number;
imageUrl?: string; imageUrl?: string;
}; };
}, },
@ -2696,7 +2817,14 @@ export const resolvers = {
name: args.input.name, name: args.input.name,
description: args.input.description, description: args.input.description,
price: args.input.price, 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, imageUrl: args.input.imageUrl,
}, },
include: { organization: true }, 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 { return {
success: true, success: true,
message: "Заказ поставки создан успешно", message: `Заказ поставки создан успешно! Добавлено ${suppliesData.length} расходников в каталог.`,
order: supplyOrder, order: supplyOrder,
}; };
} catch (error) { } catch (error) {
@ -3000,7 +3156,7 @@ export const resolvers = {
weight: args.input.weight, weight: args.input.weight,
dimensions: args.input.dimensions, dimensions: args.input.dimensions,
material: args.input.material, material: args.input.material,
images: args.input.images || [], images: JSON.stringify(args.input.images || []),
mainImage: args.input.mainImage, mainImage: args.input.mainImage,
isActive: args.input.isActive ?? true, isActive: args.input.isActive ?? true,
organizationId: currentUser.organization.id, organizationId: currentUser.organization.id,
@ -3111,7 +3267,7 @@ export const resolvers = {
weight: args.input.weight, weight: args.input.weight,
dimensions: args.input.dimensions, dimensions: args.input.dimensions,
material: args.input.material, material: args.input.material,
images: args.input.images || [], images: args.input.images ? JSON.stringify(args.input.images) : undefined,
mainImage: args.input.mainImage, mainImage: args.input.mainImage,
isActive: args.input.isActive ?? true, 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: { Message: {
type: (parent: { type?: string | null }) => { type: (parent: { type?: string | null }) => {
return parent.type || "TEXT"; return parent.type || "TEXT";

View File

@ -444,6 +444,14 @@ export const typeDefs = gql`
name: String! name: String!
description: String description: String
price: Float! price: Float!
quantity: Int!
unit: String
category: String
status: String
date: DateTime!
supplier: String
minStock: Int
currentStock: Int
imageUrl: String imageUrl: String
createdAt: DateTime! createdAt: DateTime!
updatedAt: DateTime! updatedAt: DateTime!
@ -454,6 +462,14 @@ export const typeDefs = gql`
name: String! name: String!
description: String description: String
price: Float! price: Float!
quantity: Int!
unit: String!
category: String!
status: String!
date: DateTime!
supplier: String!
minStock: Int!
currentStock: Int!
imageUrl: String imageUrl: String
} }