Добавлены новые функции для управления категориями: реализованы мутации для создания, обновления и удаления категорий. Обновлены компоненты админ-панели для отображения и управления категориями, улучшен интерфейс и адаптивность. Добавлены новые кнопки и обработчики событий для взаимодействия с категориями.
This commit is contained in:
379
EMPLOYEE_TEST_TASK.md
Normal file
379
EMPLOYEE_TEST_TASK.md
Normal 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. Краткое описание архитектурных решений
|
||||
|
||||
---
|
||||
|
||||
*Удачи в разработке! 🚀*
|
@ -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':
|
||||
|
@ -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 ${
|
||||
|
361
src/components/admin/categories-section.tsx
Normal file
361
src/components/admin/categories-section.tsx
Normal 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">
|
||||
Вы уверены, что хотите удалить категорию "{category.name}"?
|
||||
Это действие нельзя отменить. Если в категории есть товары, удаление будет невозможно.
|
||||
</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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
518
src/components/admin/ui-kit/business-demo.tsx
Normal file
518
src/components/admin/ui-kit/business-demo.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
))
|
||||
)}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
||||
{/* Поиск */}
|
||||
|
@ -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!) {
|
||||
|
@ -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) {
|
||||
|
@ -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!
|
||||
|
Reference in New Issue
Block a user