Добавлены новые поля в модель Supply и обновлены компоненты для работы с расходниками. Реализована логика загрузки и отображения чатов с непрочитанными сообщениями в мессенджере. Обновлены запросы и мутации GraphQL для поддержки новых полей. Исправлены ошибки отображения и добавлены индикаторы для непрочитанных сообщений.
This commit is contained in:
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
@ -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 = () => {
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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";
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user