From 8d57fcd7483b9cb4fe7801ca30d23b697f7802d8 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Sat, 19 Jul 2025 17:09:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=84=D1=83?= =?UTF-8?q?=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D1=8F=D0=BC=D0=B8:?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BC=D1=83=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?,=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B9?= =?UTF-8?q?.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D1=83=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D1=8F=D0=BC=D0=B8,=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=20=D0=B8=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D0=BF=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D1=8C.?= =?UTF-8?q?=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=D0=B8=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B7=D0=B0=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EMPLOYEE_TEST_TASK.md | 379 +++++++++++++ src/components/admin/admin-dashboard.tsx | 9 +- src/components/admin/admin-sidebar.tsx | 18 +- src/components/admin/categories-section.tsx | 361 ++++++++++++ src/components/admin/ui-kit-section.tsx | 8 + src/components/admin/ui-kit/business-demo.tsx | 518 ++++++++++++++++++ .../messenger/messenger-conversations.tsx | 107 ++-- .../messenger/messenger-dashboard.tsx | 186 ++++++- .../warehouse/warehouse-dashboard.tsx | 30 +- src/graphql/mutations.ts | 37 ++ src/graphql/resolvers.ts | 132 +++++ src/graphql/typedefs.ts | 15 + 12 files changed, 1733 insertions(+), 67 deletions(-) create mode 100644 EMPLOYEE_TEST_TASK.md create mode 100644 src/components/admin/categories-section.tsx create mode 100644 src/components/admin/ui-kit/business-demo.tsx diff --git a/EMPLOYEE_TEST_TASK.md b/EMPLOYEE_TEST_TASK.md new file mode 100644 index 0000000..146b7b5 --- /dev/null +++ b/EMPLOYEE_TEST_TASK.md @@ -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. Краткое описание архитектурных решений + +--- + +*Удачи в разработке! 🚀* \ No newline at end of file diff --git a/src/components/admin/admin-dashboard.tsx b/src/components/admin/admin-dashboard.tsx index edcddfc..00eb7ea 100644 --- a/src/components/admin/admin-dashboard.tsx +++ b/src/components/admin/admin-dashboard.tsx @@ -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('users') @@ -14,6 +15,12 @@ export function AdminDashboard() { switch (activeSection) { case 'users': return + case 'categories': + return ( +
+ +
+ ) case 'ui-kit': return case 'settings': diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index f6854e3..a043872 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -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 Пользователи + + + + + + ) + } + + return ( +
+
+
+

Категории товаров

+

Управление категориями для классификации товаров

+
+ +
+ {categories.length === 0 && ( + + )} + + + + + + + + Создать новую категорию + +
+
+ + setNewCategoryName(e.target.value)} + placeholder="Введите название..." + className="glass-input text-white placeholder:text-white/50" + onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()} + /> +
+
+ + +
+
+
+
+
+
+ + + + Список категорий ({categories.length}) + + + {categories.length === 0 ? ( +
+ +

Нет категорий

+

+ Создайте категории для классификации товаров +

+ +
+ ) : ( +
+ {categories.map((category) => ( +
+
+
+

{category.name}

+

+ Создано: {new Date(category.createdAt).toLocaleDateString('ru-RU')} +

+
+
+ + + + + + + + Удалить категорию + + Вы уверены, что хотите удалить категорию "{category.name}"? + Это действие нельзя отменить. Если в категории есть товары, удаление будет невозможно. + + + + + Отмена + + handleDeleteCategory(category.id)} + className="bg-red-600 hover:bg-red-700 text-white" + disabled={deleting} + > + {deleting ? 'Удаление...' : 'Удалить'} + + + + +
+
+
+ ))} +
+ )} +
+
+ + {/* Диалог редактирования */} + + + + Редактировать категорию + +
+
+ + setEditCategoryName(e.target.value)} + placeholder="Введите название..." + className="glass-input text-white placeholder:text-white/50" + onKeyDown={(e) => e.key === 'Enter' && handleUpdateCategory()} + /> +
+
+ + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/admin/ui-kit-section.tsx b/src/components/admin/ui-kit-section.tsx index 99fba44..00fe61c 100644 --- a/src/components/admin/ui-kit-section.tsx +++ b/src/components/admin/ui-kit-section.tsx @@ -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() { Интерактив + + Бизнес + @@ -118,6 +122,10 @@ export function UIKitSection() { + + + + ) diff --git a/src/components/admin/ui-kit/business-demo.tsx b/src/components/admin/ui-kit/business-demo.tsx new file mode 100644 index 0000000..05458e4 --- /dev/null +++ b/src/components/admin/ui-kit/business-demo.tsx @@ -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 ( +
+ {/* Табель рабочего времени */} + + + Табель рабочего времени + + + {/* Заголовок табеля */} +
+
+ + + ИИ + +
+

Иванов Иван Иванович

+

Менеджер по продажам • Март 2024

+
+
+
+

176 часов

+

Отработано в месяце

+
+
+ + {/* Календарь */} +
+
+
Пн
+
Вт
+
Ср
+
Чт
+
Пт
+
Сб
+
Вс
+
+ +
+ {scheduleData.map((day, index) => ( +
+
{day.day}
+
+ {day.hours > 0 && ( +
{day.hours}ч
+ )} +
+ ))} +
+
+ + {/* Легенда */} +
+
+
+ Работа +
+
+
+ Выходной +
+
+
+ Отпуск +
+
+
+ Больничный +
+
+
+ Прогул +
+
+ + {/* Статистика */} +
+
+
Рабочие дни
+
22
+
+
+
Выходные
+
8
+
+
+
Отпуск
+
0
+
+
+
Опозданий
+
2
+
+
+
+
+ + {/* Карточки товаров */} + + + Карточки товаров + + +
+ {products.map((product) => ( +
+ {/* Изображение товара */} +
+
+ +
+ + {/* Бейджи */} +
+ {product.isNew && ( + Новинка + )} + {product.oldPrice && ( + Скидка + )} +
+ + {/* Кнопки действий */} +
+ + +
+
+ + {/* Информация о товаре */} +
+
+

{product.name}

+

Артикул: {product.article}

+
+ + {/* Рейтинг и отзывы */} +
+
+ + {product.rating} +
+ ({product.reviews} отзывов) +
+ + {/* Категория и бренд */} +
+ + {product.category} + + + {product.brand} + +
+ + {/* Цена */} +
+ {product.price > 0 ? ( +
+ {formatPrice(product.price)} + {product.oldPrice && ( + {formatPrice(product.oldPrice)} + )} +
+ ) : ( + Нет в наличии + )} + + {product.inStock && product.quantity > 0 && ( +

В наличии: {product.quantity} шт.

+ )} +
+ + {/* Продавец */} +
+ Продавец: {product.seller} +
+ + {/* Кнопки */} +
+ {product.inStock && product.price > 0 ? ( + <> +
+ + {cartQuantity} + +
+ + + ) : ( + + )} +
+
+
+ ))} +
+
+
+ + {/* Карточки оптовиков */} + + + Карточки оптовиков + + +
+ {wholesalers.map((wholesaler) => ( +
+ {/* Заголовок карточки */} +
+ + + + {wholesaler.name.charAt(0)} + + + +
+
+

{wholesaler.name}

+ {wholesaler.verifiedBadges.includes('verified') && ( + Проверен + )} + {wholesaler.verifiedBadges.includes('premium') && ( + Premium + )} +
+ +

{wholesaler.fullName}

+

ИНН: {wholesaler.inn}

+ + {/* Рейтинг и статистика */} +
+
+ + {wholesaler.rating} + ({wholesaler.reviewsCount}) +
+
+ {wholesaler.completedOrders} заказов +
+
+
+
+ + {/* Описание */} +

+ {wholesaler.description} +

+ + {/* Статистика */} +
+
+
+ + Товаров +
+
{wholesaler.productsCount.toLocaleString()}
+
+ +
+
+ + Ответ +
+
{wholesaler.responseTime}
+
+
+ + {/* Категории */} +
+

Категории:

+
+ {wholesaler.categories.map((category, index) => ( + + {category} + + ))} +
+
+ + {/* Дополнительная информация */} +
+
+ + {wholesaler.location} +
+
+ + Работает с {wholesaler.workingSince} года +
+
+ + Мин. заказ: {formatPrice(wholesaler.minOrder)} +
+
+ + {/* Специальные предложения */} + {wholesaler.specialOffers > 0 && ( +
+
+ + + {wholesaler.specialOffers} специальных предложения + +
+
+ )} + + {/* Кнопки действий */} +
+ + + +
+
+ ))} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/messenger/messenger-conversations.tsx b/src/components/messenger/messenger-conversations.tsx index 9601588..d944b42 100644 --- a/src/components/messenger/messenger-conversations.tsx +++ b/src/components/messenger/messenger-conversations.tsx @@ -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 (
{/* Заголовок */} -
- -
-

Контрагенты

-

{counterparties.length} активных

+ {!compact && ( +
+ +
+

Контрагенты

+

{counterparties.length} активных

+
-
+ )} + + {/* Компактный заголовок */} + {compact && ( +
+ + {counterparties.length} +
+ )} {/* Поиск */}
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'}`} />
@@ -164,41 +176,60 @@ export function MessengerConversations({
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' }`} > -
- - {org.users?.[0]?.avatar ? ( - - ) : null} - - {getInitials(org)} - - - -
-
-

- {getOrganizationName(org)} -

- - {getTypeLabel(org.type)} - -
- -

- {getShortCompanyName(org.fullName || '')} -

+ {compact ? ( + /* Компактный режим */ +
+ + {org.users?.[0]?.avatar ? ( + + ) : null} + + {getInitials(org)} + +
-
+ ) : ( + /* Обычный режим */ +
+ + {org.users?.[0]?.avatar ? ( + + ) : null} + + {getInitials(org)} + + + +
+
+

+ {getOrganizationName(org)} +

+ + {getTypeLabel(org.type)} + +
+ +

+ {getShortCompanyName(org.fullName || '')} +

+
+
+ )}
)) )} diff --git a/src/components/messenger/messenger-dashboard.tsx b/src/components/messenger/messenger-dashboard.tsx index 4155d0d..47d655d 100644 --- a/src/components/messenger/messenger-dashboard.tsx +++ b/src/components/messenger/messenger-dashboard.tsx @@ -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(null) + const [leftPanelSize, setLeftPanelSize] = useState('normal') + const [isResizing, setIsResizing] = useState(false) + const [leftPanelWidth, setLeftPanelWidth] = useState(350) + const resizeRef = useRef(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() {
- {/* Основной контент - сетка из 2 колонок */} -
-
- {/* Левая колонка - список контрагентов */} - - - + {/* Заголовок с управлением панелями */} +
+
+

Мессенджер

+

Общение с контрагентами

+
+ + {/* Управление панелями */} +
+ + + {leftPanelSize !== 'hidden' && ( + + )} +
+
- {/* Правая колонка - чат */} - + {/* Основной контент */} +
+
+ {/* Левая панель - список контрагентов */} + {leftPanelSize !== 'hidden' && ( + <> + + + + + {/* Разделитель для изменения размера */} + {leftPanelSize === 'normal' && ( +
+
+
+
+ )} + + )} + + {/* Правая панель - чат */} + {selectedCounterparty && selectedCounterpartyData ? ( ) : ( @@ -92,6 +238,14 @@ export function MessengerDashboard() {

Начните беседу с одним из ваших контрагентов

+ {leftPanelSize === 'hidden' && ( + + )}
)} diff --git a/src/components/warehouse/warehouse-dashboard.tsx b/src/components/warehouse/warehouse-dashboard.tsx index 47908c9..bf4e311 100644 --- a/src/components/warehouse/warehouse-dashboard.tsx +++ b/src/components/warehouse/warehouse-dashboard.tsx @@ -113,16 +113,25 @@ export function WarehouseDashboard() {

Управление ассортиментом вашего склада

- - - - +
+ + + + + + @@ -136,6 +145,7 @@ export function WarehouseDashboard() { /> +
{/* Поиск */} diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index cabd456..d0c1aeb 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -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!) { diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index a44b54f..738e241 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -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) { diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 2deaa16..8bee2c9 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -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!