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

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

379
EMPLOYEE_TEST_TASK.md Normal file
View File

@ -0,0 +1,379 @@
# Тестовое задание: Система управления сотрудниками
## Описание задачи
Необходимо разработать с нуля полнофункциональную систему управления сотрудниками компании. Система должна позволять добавлять, редактировать, удалять сотрудников, а также управлять их расписанием и просматривать статистику.
## Технический стек
- **Frontend**: Next.js 15+ (App Router)
- **Backend**: GraphQL с Apollo Server
- **Database**: PostgreSQL с Prisma ORM
- **Styling**: TailwindCSS с glassmorphism эффектами
- **UI Components**: Radix UI (через shadcn/ui)
- **TypeScript**: строгая типизация
- **Icons**: Lucide React
- **Notifications**: Sonner
## Дизайн-система и стили
### Цветовая схема
- Основные цвета: оттенки фиолетового (oklch(0.75 0.32 315) до oklch(0.68 0.28 280))
- Фон: тёмный градиент `bg-gradient-smooth`
- Карточки: стеклянный эффект `glass-card`
- Текст: белый и оттенки белого/серого
### Glassmorphism стили
```css
.glass-card {
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 8px 32px rgba(168, 85, 247, 0.18),
0 4px 16px rgba(147, 51, 234, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.glass-card:hover {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 12px 40px rgba(168, 85, 247, 0.25),
0 6px 20px rgba(147, 51, 234, 0.18);
}
```
### Анимации
- Плавные переходы (0.3s ease)
- Hover-эффекты с изменением opacity и shadow
- Анимация появления форм
- Интерактивные элементы с transform
## Структура базы данных
```prisma
model Employee {
id String @id @default(cuid())
firstName String
lastName String
middleName String?
birthDate DateTime?
avatar String?
passportPhoto String?
passportSeries String?
passportNumber String?
passportIssued String?
passportDate DateTime?
address String?
position String
department String?
hireDate DateTime
salary Float?
status EmployeeStatus @default(ACTIVE)
phone String
email String?
telegram String?
whatsapp String?
emergencyContact String?
emergencyPhone String?
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scheduleRecords EmployeeSchedule[]
organization Organization @relation(fields: [organizationId], references: [id])
}
model EmployeeSchedule {
id String @id @default(cuid())
date DateTime
status ScheduleStatus
hoursWorked Float?
notes String?
employeeId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employee Employee @relation(fields: [employeeId], references: [id])
}
enum EmployeeStatus {
ACTIVE
VACATION
SICK
FIRED
}
enum ScheduleStatus {
WORK
WEEKEND
VACATION
SICK
ABSENT
}
```
## Функциональные требования
### 1. Основная страница сотрудников (/employees)
**Макет страницы:**
- Sidebar слева (аналогично dashboard)
- Основной контент справа
- Поиск по ФИО, телефону, должности
- Табы: "Сотрудники", "Расписание", "Статистика"
**Таб "Сотрудники":**
- Карточки сотрудников в grid layout (3-4 в ряд)
- Каждая карточка содержит:
- Аватар (или инициалы, если нет фото)
- ФИО
- Должность
- Телефон
- Email (если есть)
- Статус (бейдж с цветом)
- Кнопки: "Редактировать", "Уволить"
- Кнопка "Добавить сотрудника" вверху
- Фильтры по статусу
- Пагинация при большом количестве
### 2. Добавление сотрудника
**Inline форма (появляется как первая карточка):**
- Обязательные поля:
- Имя, Фамилия
- Должность
- Дата приёма
- Телефон
- Необязательные поля:
- Отчество
- Email
- Дата рождения
- Адрес
- Оклад
- Паспортные данные
- Контакты для экстренной связи
- Telegram/WhatsApp
- Загрузка аватара
- Валидация всех полей
- Красивые маски ввода (телефон, паспорт, ЗП)
### 3. Редактирование сотрудника
**Inline редактирование:**
- Форма заменяет карточку сотрудника
- Все те же поля, что при создании
- Предзаполненные данные
- Возможность изменить статус
- Кнопки "Сохранить" / "Отмена"
### 4. Управление расписанием
**Таб "Расписание":**
- Календарь на месяц
- Выбор сотрудника из dropdown
- Отметки на каждый день:
- Работа (зелёный)
- Выходной (серый)
- Отпуск (синий)
- Больничный (жёлтый)
- Прогул (красный)
- Возможность массово отметить период
- Подсчёт отработанных часов/дней
### 5. Статистика
**Таб "Статистика":**
- Общее количество сотрудников
- Распределение по статусам
- Средний возраст
- Средняя зарплата
- График найма по месяцам
- Топ-должности
- Статистика посещаемости
## GraphQL API
### Queries
```graphql
type Query {
employees: [Employee!]!
employee(id: ID!): Employee
employeeSchedule(employeeId: ID!, year: Int!, month: Int!): [EmployeeSchedule!]!
employeeStats: EmployeeStats!
}
```
### Mutations
```graphql
type Mutation {
createEmployee(input: CreateEmployeeInput!): CreateEmployeeResponse!
updateEmployee(id: ID!, input: UpdateEmployeeInput!): UpdateEmployeeResponse!
deleteEmployee(id: ID!): Boolean!
updateEmployeeSchedule(input: UpdateScheduleInput!): Boolean!
}
```
### Types
```graphql
type Employee {
id: ID!
firstName: String!
lastName: String!
middleName: String
birthDate: DateTime
avatar: String
position: String!
department: String
hireDate: DateTime!
salary: Float
status: EmployeeStatus!
phone: String!
email: String
telegram: String
whatsapp: String
emergencyContact: String
emergencyPhone: String
createdAt: DateTime!
updatedAt: DateTime!
scheduleRecords: [EmployeeSchedule!]!
}
```
## Требования к реализации
### 1. Файловая структура
```
src/
├── app/
│ └── employees/
│ └── page.tsx
├── components/
│ └── employees/
│ ├── employees-dashboard.tsx
│ ├── employee-card.tsx
│ ├── employee-form.tsx
│ ├── employee-schedule.tsx
│ └── employee-stats.tsx
├── graphql/
│ ├── queries.ts
│ ├── mutations.ts
│ ├── typedefs.ts
│ └── resolvers.ts
└── lib/
├── validations.ts
└── input-masks.ts
```
### 2. Компоненты
**EmployeesDashboard** - основной контейнер:
- Управление состоянием
- GraphQL операции
- Переключение между табами
**EmployeeCard** - карточка сотрудника:
- Отображение информации
- Кнопки действий
- Анимации hover
**EmployeeForm** - форма создания/редактирования:
- Валидация полей
- Маски ввода
- Загрузка файлов
- Обработка ошибок
**EmployeeSchedule** - календарь:
- Интерактивный календарь
- Управление статусами дней
- Подсчёт статистики
### 3. Требования к UX/UI
**Интерактивность:**
- Плавные анимации при hover
- Loading состояния для всех операций
- Оптимистичные обновления
- Toast уведомления об успехе/ошибке
**Адаптивность:**
- Корректное отображение на мобильных устройствах
- Responsive grid для карточек
- Адаптивная форма
**Доступность:**
- Поддержка клавиатурной навигации
- ARIA атрибуты
- Семантическая разметка
### 4. Валидация
**Клиентская валидация:**
- Обязательные поля
- Форматы email, телефона
- Валидные даты
- Паспортные данные
**Серверная валидация:**
- Дублирование всех проверок
- Уникальность телефона
- Проверка существования сотрудника
### 5. Обработка ошибок
- Graceful обработка всех ошибок API
- Понятные сообщения пользователю
- Retry механизм для сетевых ошибок
- Fallback состояния
## Дополнительные фичи (nice to have)
1. **Экспорт данных** - выгрузка списка сотрудников в Excel/PDF
2. **Массовые операции** - выбор нескольких сотрудников для действий
3. **Фильтры** - по отделу, статусу, дате приёма
4. **Сортировка** - по ФИО, дате приёма, зарплате
5. **История изменений** - лог всех изменений сотрудника
6. **Интеграция с мессенджерами** - отправка уведомлений
## Критерии оценки
### Обязательно (must have):
-Все основные функции работают
- ✅ Соответствие дизайн-системе
- ✅ Чистый и понятный код
- ✅ TypeScript без any
- ✅ Обработка ошибок
- ✅ Валидация данных
- ✅ Адаптивная вёрстка
### Дополнительные баллы:
- ⭐ Оптимизация производительности
- ⭐ Тесты (unit/integration)
- ⭐ Документация API
- ⭐ Инновационные решения UX
- ⭐ Дополнительные фичи
## Ресурсы
### Дизайн-референсы:
- Glassmorphism: https://css.glass/
- UI Patterns: https://ui-patterns.com/
### Технические ресурсы:
- Next.js App Router: https://nextjs.org/docs
- Prisma: https://www.prisma.io/docs
- Apollo GraphQL: https://www.apollographql.com/docs
- shadcn/ui: https://ui.shadcn.com/
## Дедлайн
**Время выполнения:** 2 часа
**Что предоставить:**
1. GitHub репозиторий с кодом
2. README с инструкциями по запуску
3. Демо на Vercel/Netlify (по возможности)
4. Краткое описание архитектурных решений
---
*Удачи в разработке! 🚀*

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>
{/* Правая колонка - чат */}
<Card className="glass-card h-full overflow-hidden">
{/* Управление панелями */}
<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>
{/* Основной контент */}
<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!