Добавлены новые функции для управления категориями: реализованы мутации для создания, обновления и удаления категорий. Обновлены компоненты админ-панели для отображения и управления категориями, улучшен интерфейс и адаптивность. Добавлены новые кнопки и обработчики событий для взаимодействия с категориями.
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 { AdminSidebar } from './admin-sidebar'
|
||||||
import { UsersSection } from './users-section'
|
import { UsersSection } from './users-section'
|
||||||
import { UIKitSection } from './ui-kit-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() {
|
export function AdminDashboard() {
|
||||||
const [activeSection, setActiveSection] = useState<AdminSection>('users')
|
const [activeSection, setActiveSection] = useState<AdminSection>('users')
|
||||||
@ -14,6 +15,12 @@ export function AdminDashboard() {
|
|||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case 'users':
|
case 'users':
|
||||||
return <UsersSection />
|
return <UsersSection />
|
||||||
|
case 'categories':
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<CategoriesSection />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
case 'ui-kit':
|
case 'ui-kit':
|
||||||
return <UIKitSection />
|
return <UIKitSection />
|
||||||
case 'settings':
|
case 'settings':
|
||||||
|
@ -9,12 +9,13 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Users,
|
Users,
|
||||||
Shield,
|
Shield,
|
||||||
Palette
|
Palette,
|
||||||
|
Package
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface AdminSidebarProps {
|
interface AdminSidebarProps {
|
||||||
activeSection: string
|
activeSection: string
|
||||||
onSectionChange: (section: 'users' | 'ui-kit' | 'settings') => void
|
onSectionChange: (section: 'users' | 'categories' | 'ui-kit' | 'settings') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSidebar({ activeSection, onSectionChange }: AdminSidebarProps) {
|
export function AdminSidebar({ activeSection, onSectionChange }: AdminSidebarProps) {
|
||||||
@ -67,6 +68,19 @@ export function AdminSidebar({ activeSection, onSectionChange }: AdminSidebarPro
|
|||||||
Пользователи
|
Пользователи
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant={activeSection === 'ui-kit' ? "secondary" : "ghost"}
|
variant={activeSection === 'ui-kit' ? "secondary" : "ghost"}
|
||||||
className={`w-full justify-start text-left transition-all duration-200 h-10 ${
|
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 { StatesDemo } from './ui-kit/states-demo'
|
||||||
import { MediaDemo } from './ui-kit/media-demo'
|
import { MediaDemo } from './ui-kit/media-demo'
|
||||||
import { InteractiveDemo } from './ui-kit/interactive-demo'
|
import { InteractiveDemo } from './ui-kit/interactive-demo'
|
||||||
|
import { BusinessDemo } from './ui-kit/business-demo'
|
||||||
|
|
||||||
export function UIKitSection() {
|
export function UIKitSection() {
|
||||||
return (
|
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 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>
|
||||||
|
<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>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="buttons" className="space-y-6">
|
<TabsContent value="buttons" className="space-y-6">
|
||||||
@ -118,6 +122,10 @@ export function UIKitSection() {
|
|||||||
<TabsContent value="interactive" className="space-y-6">
|
<TabsContent value="interactive" className="space-y-6">
|
||||||
<InteractiveDemo />
|
<InteractiveDemo />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="business" className="space-y-6">
|
||||||
|
<BusinessDemo />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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
|
loading: boolean
|
||||||
selectedCounterparty: string | null
|
selectedCounterparty: string | null
|
||||||
onSelectCounterparty: (counterpartyId: string) => void
|
onSelectCounterparty: (counterpartyId: string) => void
|
||||||
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessengerConversations({
|
export function MessengerConversations({
|
||||||
counterparties,
|
counterparties,
|
||||||
loading,
|
loading,
|
||||||
selectedCounterparty,
|
selectedCounterparty,
|
||||||
onSelectCounterparty
|
onSelectCounterparty,
|
||||||
|
compact = false
|
||||||
}: MessengerConversationsProps) {
|
}: MessengerConversationsProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
@ -129,22 +131,32 @@ export function MessengerConversations({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div className="flex items-center space-x-3 mb-4">
|
{!compact && (
|
||||||
<Users className="h-5 w-5 text-blue-400" />
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
<div>
|
<Users className="h-5 w-5 text-blue-400" />
|
||||||
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
|
<div>
|
||||||
<p className="text-white/60 text-sm">{counterparties.length} активных</p>
|
<h3 className="text-lg font-semibold text-white">Контрагенты</h3>
|
||||||
|
<p className="text-white/60 text-sm">{counterparties.length} активных</p>
|
||||||
|
</div>
|
||||||
</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">
|
<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" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Поиск по названию или ИНН..."
|
placeholder={compact ? "Поиск..." : "Поиск по названию или ИНН..."}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
@ -164,41 +176,60 @@ export function MessengerConversations({
|
|||||||
<div
|
<div
|
||||||
key={org.id}
|
key={org.id}
|
||||||
onClick={() => onSelectCounterparty(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
|
selectedCounterparty === org.id
|
||||||
? 'bg-white/20 border border-white/30'
|
? 'bg-white/20 border border-white/30'
|
||||||
: 'bg-white/5 hover:bg-white/10 border border-white/10'
|
: 'bg-white/5 hover:bg-white/10 border border-white/10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-3">
|
{compact ? (
|
||||||
<Avatar className="h-10 w-10 flex-shrink-0">
|
/* Компактный режим */
|
||||||
{org.users?.[0]?.avatar ? (
|
<div className="flex items-center justify-center">
|
||||||
<AvatarImage
|
<Avatar className="h-8 w-8">
|
||||||
src={org.users[0].avatar}
|
{org.users?.[0]?.avatar ? (
|
||||||
alt="Аватар организации"
|
<AvatarImage
|
||||||
className="w-full h-full object-cover"
|
src={org.users[0].avatar}
|
||||||
/>
|
alt="Аватар организации"
|
||||||
) : null}
|
className="w-full h-full object-cover"
|
||||||
<AvatarFallback className="bg-purple-500 text-white text-sm">
|
/>
|
||||||
{getInitials(org)}
|
) : null}
|
||||||
</AvatarFallback>
|
<AvatarFallback className="bg-purple-500 text-white text-xs">
|
||||||
</Avatar>
|
{getInitials(org)}
|
||||||
|
</AvatarFallback>
|
||||||
<div className="flex-1 min-w-0">
|
</Avatar>
|
||||||
<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>
|
) : (
|
||||||
|
/* Обычный режим */
|
||||||
|
<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>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from 'react'
|
import React, { useState, useRef, useCallback } from 'react'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { MessengerConversations } from './messenger-conversations'
|
import { MessengerConversations } from './messenger-conversations'
|
||||||
import { MessengerChat } from './messenger-chat'
|
import { MessengerChat } from './messenger-chat'
|
||||||
import { MessengerEmptyState } from './messenger-empty-state'
|
import { MessengerEmptyState } from './messenger-empty-state'
|
||||||
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||||
import { MessageCircle } from 'lucide-react'
|
import {
|
||||||
|
MessageCircle,
|
||||||
|
PanelLeftOpen,
|
||||||
|
PanelLeftClose,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Settings
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
id: string
|
id: string
|
||||||
@ -23,8 +31,14 @@ interface Organization {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LeftPanelSize = 'compact' | 'normal' | 'wide' | 'hidden'
|
||||||
|
|
||||||
export function MessengerDashboard() {
|
export function MessengerDashboard() {
|
||||||
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
|
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 { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
||||||
const counterparties = counterpartiesData?.myCounterparties || []
|
const counterparties = counterpartiesData?.myCounterparties || []
|
||||||
@ -35,6 +49,71 @@ export function MessengerDashboard() {
|
|||||||
|
|
||||||
const selectedCounterpartyData = counterparties.find((cp: Organization) => cp.id === selectedCounterparty)
|
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) {
|
if (!counterpartiesLoading && counterparties.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -65,21 +144,88 @@ export function MessengerDashboard() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
{/* Основной контент - сетка из 2 колонок */}
|
{/* Заголовок с управлением панелями */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||||
<div className="grid grid-cols-[350px_1fr] gap-4 h-full">
|
<div>
|
||||||
{/* Левая колонка - список контрагентов */}
|
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
|
||||||
<Card className="glass-card h-full overflow-hidden p-4">
|
<p className="text-white/70 text-sm">Общение с контрагентами</p>
|
||||||
<MessengerConversations
|
</div>
|
||||||
counterparties={counterparties}
|
|
||||||
loading={counterpartiesLoading}
|
|
||||||
selectedCounterparty={selectedCounterparty}
|
|
||||||
onSelectCounterparty={handleSelectCounterparty}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Правая колонка - чат */}
|
{/* Управление панелями */}
|
||||||
<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 ? (
|
{selectedCounterparty && selectedCounterpartyData ? (
|
||||||
<MessengerChat counterparty={selectedCounterpartyData} />
|
<MessengerChat counterparty={selectedCounterpartyData} />
|
||||||
) : (
|
) : (
|
||||||
@ -92,6 +238,14 @@ export function MessengerDashboard() {
|
|||||||
<p className="text-white/40 text-sm">
|
<p className="text-white/40 text-sm">
|
||||||
Начните беседу с одним из ваших контрагентов
|
Начните беседу с одним из ваших контрагентов
|
||||||
</p>
|
</p>
|
||||||
|
{leftPanelSize === 'hidden' && (
|
||||||
|
<Button
|
||||||
|
onClick={togglePanelVisibility}
|
||||||
|
className="mt-4 bg-purple-600 hover:bg-purple-700 text-white"
|
||||||
|
>
|
||||||
|
Показать список контрагентов
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -113,16 +113,25 @@ export function WarehouseDashboard() {
|
|||||||
<p className="text-white/70 text-sm">Управление ассортиментом вашего склада</p>
|
<p className="text-white/70 text-sm">Управление ассортиментом вашего склада</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<div className="flex gap-2">
|
||||||
<DialogTrigger asChild>
|
<Button
|
||||||
<Button
|
onClick={() => window.open('/admin', '_blank')}
|
||||||
onClick={handleCreateProduct}
|
variant="outline"
|
||||||
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"
|
className="bg-white/10 hover:bg-white/20 text-white border-white/20 hover:border-white/30"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
Управление категориями
|
||||||
Добавить товар
|
</Button>
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
<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">
|
<DialogContent className="glass-card max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-white">
|
<DialogTitle className="text-white">
|
||||||
@ -136,6 +145,7 @@ export function WarehouseDashboard() {
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
</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`
|
export const CREATE_EMPLOYEE = gql`
|
||||||
mutation CreateEmployee($input: CreateEmployeeInput!) {
|
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) => {
|
addToCart: async (_: unknown, args: { productId: string; quantity: number }, context: Context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
|
@ -117,6 +117,11 @@ export const typeDefs = gql`
|
|||||||
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
|
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
|
||||||
deleteProduct(id: ID!): Boolean!
|
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!
|
addToCart(productId: ID!, quantity: Int = 1): CartResponse!
|
||||||
updateCartItem(productId: ID!, quantity: Int!): CartResponse!
|
updateCartItem(productId: ID!, quantity: Int!): CartResponse!
|
||||||
@ -489,6 +494,16 @@ export const typeDefs = gql`
|
|||||||
product: Product
|
product: Product
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input CategoryInput {
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
category: Category
|
||||||
|
}
|
||||||
|
|
||||||
# Типы для корзины
|
# Типы для корзины
|
||||||
type Cart {
|
type Cart {
|
||||||
id: ID!
|
id: ID!
|
||||||
|
Reference in New Issue
Block a user