Добавлены новые функции для управления категориями: реализованы мутации для создания, обновления и удаления категорий. Обновлены компоненты админ-панели для отображения и управления категориями, улучшен интерфейс и адаптивность. Добавлены новые кнопки и обработчики событий для взаимодействия с категориями.

This commit is contained in:
Bivekich
2025-07-19 17:09:40 +03:00
parent 965482b617
commit 8d57fcd748
12 changed files with 1733 additions and 67 deletions

View File

@ -4,8 +4,9 @@ import { useState } from 'react'
import { AdminSidebar } from './admin-sidebar'
import { UsersSection } from './users-section'
import { UIKitSection } from './ui-kit-section'
import { CategoriesSection } from './categories-section'
type AdminSection = 'users' | 'ui-kit' | 'settings'
type AdminSection = 'users' | 'categories' | 'ui-kit' | 'settings'
export function AdminDashboard() {
const [activeSection, setActiveSection] = useState<AdminSection>('users')
@ -14,6 +15,12 @@ export function AdminDashboard() {
switch (activeSection) {
case 'users':
return <UsersSection />
case 'categories':
return (
<div className="p-8">
<CategoriesSection />
</div>
)
case 'ui-kit':
return <UIKitSection />
case 'settings':

View File

@ -9,12 +9,13 @@ import {
LogOut,
Users,
Shield,
Palette
Palette,
Package
} from 'lucide-react'
interface AdminSidebarProps {
activeSection: string
onSectionChange: (section: 'users' | 'ui-kit' | 'settings') => void
onSectionChange: (section: 'users' | 'categories' | 'ui-kit' | 'settings') => void
}
export function AdminSidebar({ activeSection, onSectionChange }: AdminSidebarProps) {
@ -67,6 +68,19 @@ export function AdminSidebar({ activeSection, onSectionChange }: AdminSidebarPro
Пользователи
</Button>
<Button
variant={activeSection === 'categories' ? "secondary" : "ghost"}
className={`w-full justify-start text-left transition-all duration-200 h-10 ${
activeSection === 'categories'
? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/80 hover:bg-white/10 hover:text-white'
} cursor-pointer`}
onClick={() => onSectionChange('categories')}
>
<Package className="h-4 w-4 mr-3" />
Категории
</Button>
<Button
variant={activeSection === 'ui-kit' ? "secondary" : "ghost"}
className={`w-full justify-start text-left transition-all duration-200 h-10 ${

View File

@ -0,0 +1,361 @@
"use client"
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { GET_CATEGORIES } from '@/graphql/queries'
import { CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY } from '@/graphql/mutations'
import { Plus, Edit, Trash2, Package } from 'lucide-react'
import { toast } from 'sonner'
interface Category {
id: string
name: string
createdAt: string
updatedAt: string
}
export function CategoriesSection() {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [newCategoryName, setNewCategoryName] = useState('')
const [editCategoryName, setEditCategoryName] = useState('')
const { data, loading, error, refetch } = useQuery(GET_CATEGORIES)
const [createCategory, { loading: creating }] = useMutation(CREATE_CATEGORY)
const [updateCategory, { loading: updating }] = useMutation(UPDATE_CATEGORY)
const [deleteCategory, { loading: deleting }] = useMutation(DELETE_CATEGORY)
const categories: Category[] = data?.categories || []
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) {
toast.error('Введите название категории')
return
}
try {
const { data } = await createCategory({
variables: { input: { name: newCategoryName.trim() } }
})
if (data?.createCategory?.success) {
toast.success('Категория успешно создана')
setNewCategoryName('')
setIsCreateDialogOpen(false)
refetch()
} else {
toast.error(data?.createCategory?.message || 'Ошибка при создании категории')
}
} catch (error) {
console.error('Error creating category:', error)
toast.error('Ошибка при создании категории')
}
}
const handleEditCategory = (category: Category) => {
setEditingCategory(category)
setEditCategoryName(category.name)
setIsEditDialogOpen(true)
}
const handleUpdateCategory = async () => {
if (!editingCategory || !editCategoryName.trim()) {
toast.error('Введите название категории')
return
}
try {
const { data } = await updateCategory({
variables: {
id: editingCategory.id,
input: { name: editCategoryName.trim() }
}
})
if (data?.updateCategory?.success) {
toast.success('Категория успешно обновлена')
setEditingCategory(null)
setEditCategoryName('')
setIsEditDialogOpen(false)
refetch()
} else {
toast.error(data?.updateCategory?.message || 'Ошибка при обновлении категории')
}
} catch (error) {
console.error('Error updating category:', error)
toast.error('Ошибка при обновлении категории')
}
}
const handleDeleteCategory = async (categoryId: string) => {
try {
const { data } = await deleteCategory({
variables: { id: categoryId }
})
if (data?.deleteCategory) {
toast.success('Категория успешно удалена')
refetch()
} else {
toast.error('Ошибка при удалении категории')
}
} catch (error) {
console.error('Error deleting category:', error)
const errorMessage = error instanceof Error ? error.message : 'Ошибка при удалении категории'
toast.error(errorMessage)
}
}
const handleCreateBasicCategories = async () => {
const basicCategories = [
'Электроника',
'Одежда',
'Обувь',
'Дом и сад',
'Красота и здоровье',
'Спорт и отдых',
'Автотовары',
'Детские товары',
'Продукты питания',
'Книги и канцелярия'
]
try {
for (const categoryName of basicCategories) {
await createCategory({
variables: { input: { name: categoryName } }
})
}
toast.success('Базовые категории созданы')
refetch()
} catch (error) {
console.error('Error creating basic categories:', error)
toast.error('Ошибка при создании категорий')
}
}
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">Категории товаров</h2>
</div>
<Card className="glass-card border-white/10 p-6">
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-white border-t-transparent"></div>
</div>
</Card>
</div>
)
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">Категории товаров</h2>
</div>
<Card className="glass-card border-white/10 p-6">
<div className="text-center">
<p className="text-white/70 mb-4">Ошибка загрузки категорий</p>
<Button onClick={() => refetch()} variant="outline" className="bg-white/10 text-white border-white/20">
Попробовать снова
</Button>
</div>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white">Категории товаров</h2>
<p className="text-white/70 text-sm">Управление категориями для классификации товаров</p>
</div>
<div className="flex gap-2">
{categories.length === 0 && (
<Button
onClick={handleCreateBasicCategories}
variant="outline"
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
>
Создать базовые категории
</Button>
)}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white">
<Plus className="w-4 h-4 mr-2" />
Добавить категорию
</Button>
</DialogTrigger>
<DialogContent className="glass-card">
<DialogHeader>
<DialogTitle className="text-white">Создать новую категорию</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="category-name" className="text-white">Название категории</Label>
<Input
id="category-name"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
placeholder="Введите название..."
className="glass-input text-white placeholder:text-white/50"
onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()}
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
className="bg-white/10 text-white border-white/20"
>
Отмена
</Button>
<Button
onClick={handleCreateCategory}
disabled={creating}
className="bg-gradient-to-r from-purple-600 to-pink-600 text-white"
>
{creating ? 'Создание...' : 'Создать'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<Card className="glass-card border-white/10">
<CardHeader>
<CardTitle className="text-white">Список категорий ({categories.length})</CardTitle>
</CardHeader>
<CardContent>
{categories.length === 0 ? (
<div className="text-center py-12">
<Package className="h-16 w-16 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">Нет категорий</h3>
<p className="text-white/60 text-sm mb-4">
Создайте категории для классификации товаров
</p>
<Button
onClick={handleCreateBasicCategories}
className="bg-gradient-to-r from-purple-600 to-pink-600 text-white"
>
Создать базовые категории
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map((category) => (
<div key={category.id} className="glass-card p-4 border border-white/10 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-white">{category.name}</h4>
<p className="text-white/60 text-xs">
Создано: {new Date(category.createdAt).toLocaleDateString('ru-RU')}
</p>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => handleEditCategory(category)}
className="bg-white/10 hover:bg-white/20 text-white border-white/20 h-8 w-8 p-0"
>
<Edit className="h-3 w-3" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 h-8 w-8 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="glass-card">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Удалить категорию</AlertDialogTitle>
<AlertDialogDescription className="text-white/70">
Вы уверены, что хотите удалить категорию &quot;{category.name}&quot;?
Это действие нельзя отменить. Если в категории есть товары, удаление будет невозможно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-white/10 text-white border-white/20">
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteCategory(category.id)}
className="bg-red-600 hover:bg-red-700 text-white"
disabled={deleting}
>
{deleting ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Диалог редактирования */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="glass-card">
<DialogHeader>
<DialogTitle className="text-white">Редактировать категорию</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-category-name" className="text-white">Название категории</Label>
<Input
id="edit-category-name"
value={editCategoryName}
onChange={(e) => setEditCategoryName(e.target.value)}
placeholder="Введите название..."
className="glass-input text-white placeholder:text-white/50"
onKeyDown={(e) => e.key === 'Enter' && handleUpdateCategory()}
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
className="bg-white/10 text-white border-white/20"
>
Отмена
</Button>
<Button
onClick={handleUpdateCategory}
disabled={updating}
className="bg-gradient-to-r from-purple-600 to-pink-600 text-white"
>
{updating ? 'Сохранение...' : 'Сохранить'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -15,6 +15,7 @@ import { AnimationsDemo } from './ui-kit/animations-demo'
import { StatesDemo } from './ui-kit/states-demo'
import { MediaDemo } from './ui-kit/media-demo'
import { InteractiveDemo } from './ui-kit/interactive-demo'
import { BusinessDemo } from './ui-kit/business-demo'
export function UIKitSection() {
return (
@ -65,6 +66,9 @@ export function UIKitSection() {
<TabsTrigger value="interactive" className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 text-xs px-3 py-2">
Интерактив
</TabsTrigger>
<TabsTrigger value="business" className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 text-xs px-3 py-2">
Бизнес
</TabsTrigger>
</TabsList>
<TabsContent value="buttons" className="space-y-6">
@ -118,6 +122,10 @@ export function UIKitSection() {
<TabsContent value="interactive" className="space-y-6">
<InteractiveDemo />
</TabsContent>
<TabsContent value="business" className="space-y-6">
<BusinessDemo />
</TabsContent>
</Tabs>
</div>
)

View File

@ -0,0 +1,518 @@
"use client"
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input'
import {
Calendar,
Check,
X,
Clock,
User,
Package,
Star,
Heart,
ShoppingCart,
Edit,
Trash2,
Phone,
Mail,
MapPin,
Building,
TrendingUp,
Award,
Users,
Briefcase,
Eye,
Plus,
Minus
} from 'lucide-react'
export function BusinessDemo() {
const [selectedProduct] = useState(null)
const [cartQuantity, setCartQuantity] = useState(1)
// Данные для демонстрации
const scheduleData = Array.from({ length: 30 }, (_, i) => ({
day: i + 1,
status: ['work', 'work', 'work', 'work', 'work', 'weekend', 'weekend'][i % 7],
hours: [8, 8, 8, 8, 8, 0, 0][i % 7]
}))
const products = [
{
id: '1',
name: 'iPhone 15 Pro Max 256GB',
article: 'APL-IP15PM-256',
price: 89990,
oldPrice: 99990,
quantity: 45,
category: 'Электроника',
brand: 'Apple',
rating: 4.8,
reviews: 1234,
image: '/placeholder-phone.jpg',
seller: 'TechStore Moscow',
isNew: true,
inStock: true
},
{
id: '2',
name: 'Беспроводные наушники AirPods Pro',
article: 'APL-APP-PRO',
price: 24990,
quantity: 23,
category: 'Аксессуары',
brand: 'Apple',
rating: 4.6,
reviews: 856,
image: '/placeholder-headphones.jpg',
seller: 'Audio Expert',
isNew: false,
inStock: true
},
{
id: '3',
name: 'Ноутбук MacBook Air M2',
article: 'APL-MBA-M2',
price: 0,
quantity: 0,
category: 'Компьютеры',
brand: 'Apple',
rating: 4.9,
reviews: 445,
image: '/placeholder-laptop.jpg',
seller: 'Digital World',
isNew: false,
inStock: false
}
]
const wholesalers = [
{
id: '1',
name: 'ТехноОпт Москва',
fullName: 'ООО "Технологии Оптом"',
inn: '7735123456',
type: 'WHOLESALE',
avatar: '/placeholder-company.jpg',
rating: 4.8,
reviewsCount: 2345,
productsCount: 15670,
completedOrders: 8934,
responseTime: '2 часа',
categories: ['Электроника', 'Компьютеры', 'Аксессуары'],
location: 'Москва, Россия',
workingSince: '2018',
verifiedBadges: ['verified', 'premium', 'fast-delivery'],
description: 'Крупнейший поставщик электроники и компьютерной техники в России',
specialOffers: 3,
minOrder: 50000
},
{
id: '2',
name: 'СтройБаза Регион',
fullName: 'ИП Строительные материалы',
inn: '7735987654',
type: 'WHOLESALE',
avatar: '/placeholder-construction.jpg',
rating: 4.5,
reviewsCount: 1876,
productsCount: 8430,
completedOrders: 5621,
responseTime: '4 часа',
categories: ['Стройматериалы', 'Инструменты', 'Сантехника'],
location: 'Екатеринбург, Россия',
workingSince: '2015',
verifiedBadges: ['verified', 'eco-friendly'],
description: 'Надежный поставщик строительных материалов по всей России',
specialOffers: 1,
minOrder: 30000
}
]
const getStatusColor = (status: string) => {
switch (status) {
case 'work': return 'bg-green-500'
case 'weekend': return 'bg-gray-400'
case 'vacation': return 'bg-blue-500'
case 'sick': return 'bg-yellow-500'
case 'absent': return 'bg-red-500'
default: return 'bg-gray-400'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'work': return 'Работа'
case 'weekend': return 'Выходной'
case 'vacation': return 'Отпуск'
case 'sick': return 'Больничный'
case 'absent': return 'Прогул'
default: return 'Неизвестно'
}
}
const formatPrice = (price: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(price)
}
return (
<div className="space-y-6">
{/* Табель рабочего времени */}
<Card className="glass-card border-white/10">
<CardHeader>
<CardTitle className="text-white">Табель рабочего времени</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Заголовок табеля */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Avatar className="h-10 w-10">
<AvatarImage src="/placeholder-employee.jpg" />
<AvatarFallback className="bg-purple-600 text-white">ИИ</AvatarFallback>
</Avatar>
<div>
<h4 className="text-white font-medium">Иванов Иван Иванович</h4>
<p className="text-white/60 text-sm">Менеджер по продажам Март 2024</p>
</div>
</div>
<div className="text-right">
<p className="text-white text-lg font-bold">176 часов</p>
<p className="text-white/60 text-sm">Отработано в месяце</p>
</div>
</div>
{/* Календарь */}
<div className="space-y-4">
<div className="grid grid-cols-7 gap-2 text-center text-sm text-white/70">
<div>Пн</div>
<div>Вт</div>
<div>Ср</div>
<div>Чт</div>
<div>Пт</div>
<div>Сб</div>
<div>Вс</div>
</div>
<div className="grid grid-cols-7 gap-2">
{scheduleData.map((day, index) => (
<div
key={index}
className={`
relative p-3 rounded-lg border border-white/10 text-center transition-all hover:border-white/30
${day.status === 'work' ? 'bg-green-500/20' : ''}
${day.status === 'weekend' ? 'bg-gray-500/20' : ''}
`}
>
<div className="text-white text-sm font-medium">{day.day}</div>
<div className={`w-2 h-2 rounded-full mx-auto mt-1 ${getStatusColor(day.status)}`}></div>
{day.hours > 0 && (
<div className="text-white/60 text-xs mt-1">{day.hours}ч</div>
)}
</div>
))}
</div>
</div>
{/* Легенда */}
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span className="text-white/70">Работа</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400"></div>
<span className="text-white/70">Выходной</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span className="text-white/70">Отпуск</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
<span className="text-white/70">Больничный</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<span className="text-white/70">Прогул</span>
</div>
</div>
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="glass-card p-3 rounded-lg border border-white/10">
<div className="text-white/60 text-xs">Рабочие дни</div>
<div className="text-white text-lg font-bold">22</div>
</div>
<div className="glass-card p-3 rounded-lg border border-white/10">
<div className="text-white/60 text-xs">Выходные</div>
<div className="text-white text-lg font-bold">8</div>
</div>
<div className="glass-card p-3 rounded-lg border border-white/10">
<div className="text-white/60 text-xs">Отпуск</div>
<div className="text-white text-lg font-bold">0</div>
</div>
<div className="glass-card p-3 rounded-lg border border-white/10">
<div className="text-white/60 text-xs">Опозданий</div>
<div className="text-white text-lg font-bold">2</div>
</div>
</div>
</CardContent>
</Card>
{/* Карточки товаров */}
<Card className="glass-card border-white/10">
<CardHeader>
<CardTitle className="text-white">Карточки товаров</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{products.map((product) => (
<div key={product.id} className="glass-card p-4 rounded-lg border border-white/10 group hover:border-white/30 transition-all">
{/* Изображение товара */}
<div className="relative mb-3">
<div className="w-full h-40 bg-white/10 rounded-lg flex items-center justify-center">
<Package className="h-16 w-16 text-white/40" />
</div>
{/* Бейджи */}
<div className="absolute top-2 left-2 flex flex-col gap-1">
{product.isNew && (
<Badge className="bg-green-600 text-white text-xs">Новинка</Badge>
)}
{product.oldPrice && (
<Badge className="bg-red-600 text-white text-xs">Скидка</Badge>
)}
</div>
{/* Кнопки действий */}
<div className="absolute top-2 right-2 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" variant="outline" className="bg-white/20 hover:bg-white/30 text-white border-white/30 h-8 w-8 p-0">
<Heart className="h-3 w-3" />
</Button>
<Button size="sm" variant="outline" className="bg-white/20 hover:bg-white/30 text-white border-white/30 h-8 w-8 p-0">
<Eye className="h-3 w-3" />
</Button>
</div>
</div>
{/* Информация о товаре */}
<div className="space-y-2">
<div>
<h4 className="text-white font-medium text-sm line-clamp-2">{product.name}</h4>
<p className="text-white/60 text-xs">Артикул: {product.article}</p>
</div>
{/* Рейтинг и отзывы */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
<span className="text-white/80 text-xs">{product.rating}</span>
</div>
<span className="text-white/60 text-xs">({product.reviews} отзывов)</span>
</div>
{/* Категория и бренд */}
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-xs border-white/30 text-white/70">
{product.category}
</Badge>
<Badge variant="outline" className="text-xs border-white/30 text-white/70">
{product.brand}
</Badge>
</div>
{/* Цена */}
<div className="space-y-1">
{product.price > 0 ? (
<div className="flex items-center gap-2">
<span className="text-white font-bold">{formatPrice(product.price)}</span>
{product.oldPrice && (
<span className="text-white/60 text-sm line-through">{formatPrice(product.oldPrice)}</span>
)}
</div>
) : (
<span className="text-red-400 font-medium">Нет в наличии</span>
)}
{product.inStock && product.quantity > 0 && (
<p className="text-green-400 text-xs">В наличии: {product.quantity} шт.</p>
)}
</div>
{/* Продавец */}
<div className="text-white/60 text-xs">
Продавец: {product.seller}
</div>
{/* Кнопки */}
<div className="flex gap-2 pt-2">
{product.inStock && product.price > 0 ? (
<>
<div className="flex items-center border border-white/30 rounded">
<Button size="sm" variant="ghost" className="h-7 w-7 p-0 text-white hover:bg-white/20">
<Minus className="h-3 w-3" />
</Button>
<span className="px-2 text-white text-sm">{cartQuantity}</span>
<Button size="sm" variant="ghost" className="h-7 w-7 p-0 text-white hover:bg-white/20">
<Plus className="h-3 w-3" />
</Button>
</div>
<Button size="sm" className="flex-1 bg-purple-600 hover:bg-purple-700 text-white">
<ShoppingCart className="h-3 w-3 mr-1" />
В корзину
</Button>
</>
) : (
<Button size="sm" disabled className="w-full bg-gray-600 text-white/50">
Недоступно
</Button>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Карточки оптовиков */}
<Card className="glass-card border-white/10">
<CardHeader>
<CardTitle className="text-white">Карточки оптовиков</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{wholesalers.map((wholesaler) => (
<div key={wholesaler.id} className="glass-card p-6 rounded-lg border border-white/10 hover:border-white/30 transition-all">
{/* Заголовок карточки */}
<div className="flex items-start gap-4 mb-4">
<Avatar className="h-16 w-16">
<AvatarImage src={wholesaler.avatar} />
<AvatarFallback className="bg-purple-600 text-white text-lg">
{wholesaler.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-white font-semibold text-lg truncate">{wholesaler.name}</h4>
{wholesaler.verifiedBadges.includes('verified') && (
<Badge className="bg-green-600 text-white text-xs">Проверен</Badge>
)}
{wholesaler.verifiedBadges.includes('premium') && (
<Badge className="bg-yellow-600 text-white text-xs">Premium</Badge>
)}
</div>
<p className="text-white/70 text-sm mb-2">{wholesaler.fullName}</p>
<p className="text-white/60 text-xs">ИНН: {wholesaler.inn}</p>
{/* Рейтинг и статистика */}
<div className="flex items-center gap-4 mt-2 text-sm">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<span className="text-white">{wholesaler.rating}</span>
<span className="text-white/60">({wholesaler.reviewsCount})</span>
</div>
<div className="text-white/60">
{wholesaler.completedOrders} заказов
</div>
</div>
</div>
</div>
{/* Описание */}
<p className="text-white/70 text-sm mb-4 line-clamp-2">
{wholesaler.description}
</p>
{/* Статистика */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-white/5 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Package className="h-4 w-4 text-purple-400" />
<span className="text-white/70 text-xs">Товаров</span>
</div>
<div className="text-white font-bold">{wholesaler.productsCount.toLocaleString()}</div>
</div>
<div className="bg-white/5 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Clock className="h-4 w-4 text-green-400" />
<span className="text-white/70 text-xs">Ответ</span>
</div>
<div className="text-white font-bold">{wholesaler.responseTime}</div>
</div>
</div>
{/* Категории */}
<div className="mb-4">
<p className="text-white/70 text-xs mb-2">Категории:</p>
<div className="flex flex-wrap gap-1">
{wholesaler.categories.map((category, index) => (
<Badge key={index} variant="outline" className="text-xs border-white/30 text-white/70">
{category}
</Badge>
))}
</div>
</div>
{/* Дополнительная информация */}
<div className="space-y-2 mb-4 text-sm">
<div className="flex items-center gap-2 text-white/60">
<MapPin className="h-3 w-3" />
<span>{wholesaler.location}</span>
</div>
<div className="flex items-center gap-2 text-white/60">
<Calendar className="h-3 w-3" />
<span>Работает с {wholesaler.workingSince} года</span>
</div>
<div className="flex items-center gap-2 text-white/60">
<Briefcase className="h-3 w-3" />
<span>Мин. заказ: {formatPrice(wholesaler.minOrder)}</span>
</div>
</div>
{/* Специальные предложения */}
{wholesaler.specialOffers > 0 && (
<div className="bg-orange-500/20 border border-orange-500/30 rounded-lg p-3 mb-4">
<div className="flex items-center gap-2">
<Award className="h-4 w-4 text-orange-400" />
<span className="text-orange-200 font-medium text-sm">
{wholesaler.specialOffers} специальных предложения
</span>
</div>
</div>
)}
{/* Кнопки действий */}
<div className="flex gap-2">
<Button className="flex-1 bg-purple-600 hover:bg-purple-700 text-white">
<Eye className="h-4 w-4 mr-2" />
Смотреть товары
</Button>
<Button variant="outline" className="bg-white/10 hover:bg-white/20 text-white border-white/30">
<Phone className="h-4 w-4" />
</Button>
<Button variant="outline" className="bg-white/10 hover:bg-white/20 text-white border-white/30">
<Mail className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -25,13 +25,15 @@ interface MessengerConversationsProps {
loading: boolean
selectedCounterparty: string | null
onSelectCounterparty: (counterpartyId: string) => void
compact?: boolean
}
export function MessengerConversations({
counterparties,
loading,
selectedCounterparty,
onSelectCounterparty
onSelectCounterparty,
compact = false
}: MessengerConversationsProps) {
const [searchTerm, setSearchTerm] = useState('')
@ -129,22 +131,32 @@ export function MessengerConversations({
return (
<div className="flex flex-col h-full">
{/* Заголовок */}
<div className="flex items-center space-x-3 mb-4">
<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>
{!compact && (
<div className="flex items-center space-x-3 mb-4">
<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>
</div>
</div>
</div>
)}
{/* Компактный заголовок */}
{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>
</div>
)}
{/* Поиск */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
<Input
placeholder="Поиск по названию или ИНН..."
placeholder={compact ? "Поиск..." : "Поиск по названию или ИНН..."}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="glass-input text-white placeholder:text-white/40 pl-10 h-10"
className={`glass-input text-white placeholder:text-white/40 pl-10 ${compact ? 'h-8 text-sm' : 'h-10'}`}
/>
</div>
@ -164,41 +176,60 @@ export function MessengerConversations({
<div
key={org.id}
onClick={() => onSelectCounterparty(org.id)}
className={`p-3 rounded-lg cursor-pointer transition-all duration-200 ${
className={`${compact ? 'p-2' : 'p-3'} rounded-lg cursor-pointer transition-all duration-200 ${
selectedCounterparty === org.id
? 'bg-white/20 border border-white/30'
: 'bg-white/5 hover:bg-white/10 border border-white/10'
}`}
>
<div className="flex items-start space-x-3">
<Avatar className="h-10 w-10 flex-shrink-0">
{org.users?.[0]?.avatar ? (
<AvatarImage
src={org.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials(org)}
</AvatarFallback>
</Avatar>
<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">
{getOrganizationName(org)}
</h4>
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0 ml-2`}>
{getTypeLabel(org.type)}
</Badge>
</div>
<p className="text-white/60 text-xs truncate">
{getShortCompanyName(org.fullName || '')}
</p>
{compact ? (
/* Компактный режим */
<div className="flex items-center justify-center">
<Avatar className="h-8 w-8">
{org.users?.[0]?.avatar ? (
<AvatarImage
src={org.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-xs">
{getInitials(org)}
</AvatarFallback>
</Avatar>
</div>
</div>
) : (
/* Обычный режим */
<div className="flex items-start space-x-3">
<Avatar className="h-10 w-10 flex-shrink-0">
{org.users?.[0]?.avatar ? (
<AvatarImage
src={org.users[0].avatar}
alt="Аватар организации"
className="w-full h-full object-cover"
/>
) : null}
<AvatarFallback className="bg-purple-500 text-white text-sm">
{getInitials(org)}
</AvatarFallback>
</Avatar>
<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">
{getOrganizationName(org)}
</h4>
<Badge className={`${getTypeColor(org.type)} text-xs flex-shrink-0 ml-2`}>
{getTypeLabel(org.type)}
</Badge>
</div>
<p className="text-white/60 text-xs truncate">
{getShortCompanyName(org.fullName || '')}
</p>
</div>
</div>
)}
</div>
))
)}

View File

@ -1,14 +1,22 @@
"use client"
import { useState } from 'react'
import React, { useState, useRef, useCallback } from 'react'
import { useQuery } from '@apollo/client'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Sidebar } from '@/components/dashboard/sidebar'
import { MessengerConversations } from './messenger-conversations'
import { MessengerChat } from './messenger-chat'
import { MessengerEmptyState } from './messenger-empty-state'
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
import { MessageCircle } from 'lucide-react'
import {
MessageCircle,
PanelLeftOpen,
PanelLeftClose,
Maximize2,
Minimize2,
Settings
} from 'lucide-react'
interface Organization {
id: string
@ -23,8 +31,14 @@ interface Organization {
createdAt: string
}
type LeftPanelSize = 'compact' | 'normal' | 'wide' | 'hidden'
export function MessengerDashboard() {
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
const [leftPanelSize, setLeftPanelSize] = useState<LeftPanelSize>('normal')
const [isResizing, setIsResizing] = useState(false)
const [leftPanelWidth, setLeftPanelWidth] = useState(350)
const resizeRef = useRef<HTMLDivElement>(null)
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
const counterparties = counterpartiesData?.myCounterparties || []
@ -35,6 +49,71 @@ export function MessengerDashboard() {
const selectedCounterpartyData = counterparties.find((cp: Organization) => cp.id === selectedCounterparty)
// Получение ширины для разных размеров панели
const getPanelWidth = (size: LeftPanelSize) => {
switch (size) {
case 'hidden': return 0
case 'compact': return 280
case 'normal': return 350
case 'wide': return 450
default: return 350
}
}
const currentWidth = leftPanelSize === 'normal' ? leftPanelWidth : getPanelWidth(leftPanelSize)
// Обработка изменения размера панели
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsResizing(true)
e.preventDefault()
}, [])
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isResizing) return
const newWidth = Math.min(Math.max(280, e.clientX - 56 - 24), 600) // 56px sidebar + 24px padding
setLeftPanelWidth(newWidth)
setLeftPanelSize('normal')
}, [isResizing])
const handleMouseUp = useCallback(() => {
setIsResizing(false)
}, [])
// Добавляем глобальные обработчики для изменения размера
React.useEffect(() => {
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
} else {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, handleMouseMove, handleMouseUp])
// Переключение размеров панели
const togglePanelSize = () => {
const sizes: LeftPanelSize[] = ['compact', 'normal', 'wide']
const currentIndex = sizes.indexOf(leftPanelSize)
const nextIndex = (currentIndex + 1) % sizes.length
setLeftPanelSize(sizes[nextIndex])
}
const togglePanelVisibility = () => {
setLeftPanelSize(leftPanelSize === 'hidden' ? 'normal' : 'hidden')
}
// Если нет контрагентов, показываем заглушку
if (!counterpartiesLoading && counterparties.length === 0) {
return (
@ -65,21 +144,88 @@ export function MessengerDashboard() {
<Sidebar />
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
<div className="h-full w-full flex flex-col">
{/* Основной контент - сетка из 2 колонок */}
<div className="flex-1 overflow-hidden">
<div className="grid grid-cols-[350px_1fr] gap-4 h-full">
{/* Левая колонка - список контрагентов */}
<Card className="glass-card h-full overflow-hidden p-4">
<MessengerConversations
counterparties={counterparties}
loading={counterpartiesLoading}
selectedCounterparty={selectedCounterparty}
onSelectCounterparty={handleSelectCounterparty}
/>
</Card>
{/* Заголовок с управлением панелями */}
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
<p className="text-white/70 text-sm">Общение с контрагентами</p>
</div>
{/* Управление панелями */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={togglePanelVisibility}
className="bg-white/10 hover:bg-white/20 text-white border-white/20 h-8"
title={leftPanelSize === 'hidden' ? 'Показать список' : 'Скрыть список'}
>
{leftPanelSize === 'hidden' ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button>
{leftPanelSize !== 'hidden' && (
<Button
variant="outline"
size="sm"
onClick={togglePanelSize}
className="bg-white/10 hover:bg-white/20 text-white border-white/20 h-8"
title="Изменить размер списка"
>
{leftPanelSize === 'compact' ? (
<Minimize2 className="h-4 w-4" />
) : leftPanelSize === 'wide' ? (
<Maximize2 className="h-4 w-4" />
) : (
<Settings className="h-4 w-4" />
)}
<span className="ml-1 text-xs">
{leftPanelSize === 'compact' ? 'Компакт' :
leftPanelSize === 'wide' ? 'Широкий' : 'Обычный'}
</span>
</Button>
)}
</div>
</div>
{/* Правая колонка - чат */}
<Card className="glass-card h-full overflow-hidden">
{/* Основной контент */}
<div className="flex-1 overflow-hidden">
<div className="flex gap-4 h-full">
{/* Левая панель - список контрагентов */}
{leftPanelSize !== 'hidden' && (
<>
<Card
className="glass-card h-full overflow-hidden p-4 transition-all duration-200 ease-in-out"
style={{ width: `${currentWidth}px` }}
>
<MessengerConversations
counterparties={counterparties}
loading={counterpartiesLoading}
selectedCounterparty={selectedCounterparty}
onSelectCounterparty={handleSelectCounterparty}
compact={leftPanelSize === 'compact'}
/>
</Card>
{/* Разделитель для изменения размера */}
{leftPanelSize === 'normal' && (
<div
ref={resizeRef}
className="w-1 bg-white/10 hover:bg-white/20 cursor-col-resize transition-colors relative group"
onMouseDown={handleMouseDown}
>
<div className="absolute inset-y-0 -inset-x-1 group-hover:bg-white/5 transition-colors" />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-3 h-8 bg-white/20 rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)}
</>
)}
{/* Правая панель - чат */}
<Card className="glass-card h-full overflow-hidden flex-1">
{selectedCounterparty && selectedCounterpartyData ? (
<MessengerChat counterparty={selectedCounterpartyData} />
) : (
@ -92,6 +238,14 @@ export function MessengerDashboard() {
<p className="text-white/40 text-sm">
Начните беседу с одним из ваших контрагентов
</p>
{leftPanelSize === 'hidden' && (
<Button
onClick={togglePanelVisibility}
className="mt-4 bg-purple-600 hover:bg-purple-700 text-white"
>
Показать список контрагентов
</Button>
)}
</div>
</div>
)}

View File

@ -113,16 +113,25 @@ export function WarehouseDashboard() {
<p className="text-white/70 text-sm">Управление ассортиментом вашего склада</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={handleCreateProduct}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</DialogTrigger>
<div className="flex gap-2">
<Button
onClick={() => window.open('/admin', '_blank')}
variant="outline"
className="bg-white/10 hover:bg-white/20 text-white border-white/20 hover:border-white/30"
>
Управление категориями
</Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={handleCreateProduct}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</DialogTrigger>
<DialogContent className="glass-card max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">
@ -136,6 +145,7 @@ export function WarehouseDashboard() {
/>
</DialogContent>
</Dialog>
</div>
</div>
{/* Поиск */}

View File

@ -919,6 +919,43 @@ export const REMOVE_FROM_FAVORITES = gql`
}
`
// Мутации для категорий
export const CREATE_CATEGORY = gql`
mutation CreateCategory($input: CategoryInput!) {
createCategory(input: $input) {
success
message
category {
id
name
createdAt
updatedAt
}
}
}
`
export const UPDATE_CATEGORY = gql`
mutation UpdateCategory($id: ID!, $input: CategoryInput!) {
updateCategory(id: $id, input: $input) {
success
message
category {
id
name
createdAt
updatedAt
}
}
}
`
export const DELETE_CATEGORY = gql`
mutation DeleteCategory($id: ID!) {
deleteCategory(id: $id)
}
`
// Мутации для сотрудников
export const CREATE_EMPLOYEE = gql`
mutation CreateEmployee($input: CreateEmployeeInput!) {

View File

@ -2733,6 +2733,138 @@ export const resolvers = {
}
},
// Создать категорию
createCategory: async (_: unknown, args: { input: { name: string } }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
// Проверяем уникальность названия категории
const existingCategory = await prisma.category.findUnique({
where: { name: args.input.name }
})
if (existingCategory) {
return {
success: false,
message: 'Категория с таким названием уже существует'
}
}
try {
const category = await prisma.category.create({
data: {
name: args.input.name
}
})
return {
success: true,
message: 'Категория успешно создана',
category
}
} catch (error) {
console.error('Error creating category:', error)
return {
success: false,
message: 'Ошибка при создании категории'
}
}
},
// Обновить категорию
updateCategory: async (_: unknown, args: { id: string; input: { name: string } }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
// Проверяем существование категории
const existingCategory = await prisma.category.findUnique({
where: { id: args.id }
})
if (!existingCategory) {
return {
success: false,
message: 'Категория не найдена'
}
}
// Проверяем уникальность нового названия (если изменилось)
if (args.input.name !== existingCategory.name) {
const duplicateCategory = await prisma.category.findUnique({
where: { name: args.input.name }
})
if (duplicateCategory) {
return {
success: false,
message: 'Категория с таким названием уже существует'
}
}
}
try {
const category = await prisma.category.update({
where: { id: args.id },
data: {
name: args.input.name
}
})
return {
success: true,
message: 'Категория успешно обновлена',
category
}
} catch (error) {
console.error('Error updating category:', error)
return {
success: false,
message: 'Ошибка при обновлении категории'
}
}
},
// Удалить категорию
deleteCategory: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
// Проверяем существование категории
const existingCategory = await prisma.category.findUnique({
where: { id: args.id },
include: { products: true }
})
if (!existingCategory) {
throw new GraphQLError('Категория не найдена')
}
// Проверяем, есть ли товары в этой категории
if (existingCategory.products.length > 0) {
throw new GraphQLError('Нельзя удалить категорию, в которой есть товары')
}
try {
await prisma.category.delete({
where: { id: args.id }
})
return true
} catch (error) {
console.error('Error deleting category:', error)
return false
}
},
// Добавить товар в корзину
addToCart: async (_: unknown, args: { productId: string; quantity: number }, context: Context) => {
if (!context.user) {

View File

@ -117,6 +117,11 @@ export const typeDefs = gql`
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
deleteProduct(id: ID!): Boolean!
# Работа с категориями
createCategory(input: CategoryInput!): CategoryResponse!
updateCategory(id: ID!, input: CategoryInput!): CategoryResponse!
deleteCategory(id: ID!): Boolean!
# Работа с корзиной
addToCart(productId: ID!, quantity: Int = 1): CartResponse!
updateCartItem(productId: ID!, quantity: Int!): CartResponse!
@ -489,6 +494,16 @@ export const typeDefs = gql`
product: Product
}
input CategoryInput {
name: String!
}
type CategoryResponse {
success: Boolean!
message: String!
category: Category
}
# Типы для корзины
type Cart {
id: ID!