Удален файл с тестовым заданием по системе управления сотрудниками. Обновлены зависимости в package.json и package-lock.json, добавлен новый пакет react-resizable-panels. Внесены изменения в компоненты для улучшения работы боковой панели и отображения дат. Добавлены новые функции для обработки дат в формате DateTime в GraphQL.
This commit is contained in:
@ -1,379 +0,0 @@
|
||||
# Тестовое задание: Система управления сотрудниками
|
||||
|
||||
## Описание задачи
|
||||
|
||||
Необходимо разработать с нуля полнофункциональную систему управления сотрудниками компании. Система должна позволять добавлять, редактировать, удалять сотрудников, а также управлять их расписанием и просматривать статистику.
|
||||
|
||||
## Технический стек
|
||||
|
||||
- **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. Краткое описание архитектурных решений
|
||||
|
||||
---
|
||||
|
||||
*Удачи в разработке! 🚀*
|
11
package-lock.json
generated
11
package-lock.json
generated
@ -48,6 +48,7 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-imask": "^7.6.1",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
@ -9634,6 +9635,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable-panels": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.3.tgz",
|
||||
"integrity": "sha512-7HA8THVBHTzhDK4ON0tvlGXyMAJN1zBeRpuyyremSikgYh2ku6ltD7tsGQOcXx4NKPrZtYCm/5CBr+dkruTGQw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
|
@ -49,6 +49,7 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-imask": "^7.6.1",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
import { ApolloProvider } from '@apollo/client'
|
||||
import { apolloClient } from '@/lib/apollo-client'
|
||||
import { SidebarProvider } from '@/hooks/useSidebar'
|
||||
|
||||
export function Providers({
|
||||
children,
|
||||
@ -10,7 +11,9 @@ export function Providers({
|
||||
}) {
|
||||
return (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<SidebarProvider>
|
||||
{children}
|
||||
</SidebarProvider>
|
||||
</ApolloProvider>
|
||||
)
|
||||
}
|
@ -27,6 +27,22 @@ export function CategoriesSection() {
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editCategoryName, setEditCategoryName] = useState('')
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Неизвестно'
|
||||
}
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
} catch (error) {
|
||||
return 'Неизвестно'
|
||||
}
|
||||
}
|
||||
|
||||
const { data, loading, error, refetch } = useQuery(GET_CATEGORIES)
|
||||
const [createCategory, { loading: creating }] = useMutation(CREATE_CATEGORY)
|
||||
const [updateCategory, { loading: updating }] = useMutation(UPDATE_CATEGORY)
|
||||
@ -266,7 +282,7 @@ export function CategoriesSection() {
|
||||
<div>
|
||||
<h4 className="font-medium text-white">{category.name}</h4>
|
||||
<p className="text-white/60 text-xs">
|
||||
Создано: {new Date(category.createdAt).toLocaleDateString('ru-RU')}
|
||||
Создано: {formatDate(category.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
|
@ -96,13 +96,21 @@ export function UsersSection() {
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Неизвестно'
|
||||
}
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch (error) {
|
||||
return 'Неизвестно'
|
||||
}
|
||||
}
|
||||
|
||||
const getInitials = (name?: string, phone?: string) => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
@ -14,13 +15,16 @@ import {
|
||||
Warehouse,
|
||||
Users,
|
||||
Truck,
|
||||
Handshake
|
||||
Handshake,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react'
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { isCollapsed, toggleSidebar } = useSidebar()
|
||||
|
||||
const getInitials = () => {
|
||||
const orgName = getOrganizationName()
|
||||
@ -86,6 +90,8 @@ export function Sidebar() {
|
||||
router.push('/partners')
|
||||
}
|
||||
|
||||
|
||||
|
||||
const isSettingsActive = pathname === '/settings'
|
||||
const isMarketActive = pathname.startsWith('/market')
|
||||
const isMessengerActive = pathname.startsWith('/messenger')
|
||||
@ -96,15 +102,31 @@ export function Sidebar() {
|
||||
const isPartnersActive = pathname.startsWith('/partners')
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-full w-56 bg-white/10 backdrop-blur-xl border-r border-white/20 p-3">
|
||||
<div className={`fixed left-4 top-4 bottom-4 ${isCollapsed ? 'w-14' : 'w-72'} bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl ${isCollapsed ? 'p-2' : 'p-3'} transition-all duration-300 ease-in-out z-50`}>
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
{/* Кнопка сворачивания */}
|
||||
<div className={`flex ${isCollapsed ? 'justify-center' : 'justify-end'} ${isCollapsed ? 'mb-2' : 'mb-3'}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={`${isCollapsed ? 'h-7 w-7' : 'h-8 w-8'} text-white/60 hover:text-white hover:bg-white/10 transition-all duration-200`}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Информация о пользователе */}
|
||||
<Card className="bg-gradient-to-br from-white/15 to-white/5 backdrop-blur border border-white/30 p-4 mb-4 shadow-lg">
|
||||
{!isCollapsed ? (
|
||||
// Развернутое состояние
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="relative">
|
||||
<Avatar className="h-12 w-12 flex-shrink-0 ring-2 ring-white/20">
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className="h-12 w-12 ring-2 ring-white/20">
|
||||
{user?.avatar ? (
|
||||
<AvatarImage
|
||||
src={user.avatar}
|
||||
@ -119,7 +141,7 @@ export function Sidebar() {
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-400 rounded-full border-2 border-white/20"></div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-semibold truncate mb-1" title={getOrganizationName()}>
|
||||
<p className="text-white text-sm font-semibold mb-1 break-words" title={getOrganizationName()}>
|
||||
{getOrganizationName()}
|
||||
</p>
|
||||
<div className="flex items-center space-x-1">
|
||||
@ -130,62 +152,99 @@ export function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Свернутое состояние
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative mb-2">
|
||||
<Avatar className="h-10 w-10 ring-2 ring-white/20">
|
||||
{user?.avatar ? (
|
||||
<AvatarImage
|
||||
src={user.avatar}
|
||||
alt="Аватар пользователя"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-xs font-semibold">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-400 rounded-full border border-white/20"></div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-white text-[10px] font-semibold leading-tight max-w-full break-words"
|
||||
title={getOrganizationName()}
|
||||
style={{ fontSize: '9px', lineHeight: '11px' }}>
|
||||
{getOrganizationName().length > 12
|
||||
? getOrganizationName().substring(0, 12) + '...'
|
||||
: getOrganizationName()
|
||||
}
|
||||
</p>
|
||||
<div className="flex items-center justify-center mt-1">
|
||||
<div className="w-1.5 h-1.5 bg-purple-400 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Навигация */}
|
||||
<div className="space-y-1 mb-3">
|
||||
<div className="space-y-1 mb-3 flex-1">
|
||||
<Button
|
||||
variant={isMarketActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
isMarketActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handleMarketClick}
|
||||
title={isCollapsed ? "Маркет" : ""}
|
||||
>
|
||||
<Store className="h-3 w-3 mr-2" />
|
||||
Маркет
|
||||
<Store className={`${isCollapsed ? 'h-4 w-4' : 'h-4 w-4'} flex-shrink-0`} />
|
||||
{!isCollapsed && <span className="ml-3">Маркет</span>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isMessengerActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
isMessengerActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handleMessengerClick}
|
||||
title={isCollapsed ? "Мессенджер" : ""}
|
||||
>
|
||||
<MessageCircle className="h-3 w-3 mr-2" />
|
||||
Мессенджер
|
||||
<MessageCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Мессенджер</span>}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isPartnersActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
isPartnersActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handlePartnersClick}
|
||||
title={isCollapsed ? "Партнёры" : ""}
|
||||
>
|
||||
<Handshake className="h-3 w-3 mr-2" />
|
||||
Партнёры
|
||||
<Handshake className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Партнёры</span>}
|
||||
</Button>
|
||||
|
||||
{/* Услуги - только для фулфилмент центров */}
|
||||
{user?.organization?.type === 'FULFILLMENT' && (
|
||||
<Button
|
||||
variant={isServicesActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
isServicesActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handleServicesClick}
|
||||
title={isCollapsed ? "Услуги" : ""}
|
||||
>
|
||||
<Wrench className="h-3 w-3 mr-2" />
|
||||
Услуги
|
||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Услуги</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -193,15 +252,16 @@ export function Sidebar() {
|
||||
{user?.organization?.type === 'FULFILLMENT' && (
|
||||
<Button
|
||||
variant={isEmployeesActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
isEmployeesActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handleEmployeesClick}
|
||||
title={isCollapsed ? "Сотрудники" : ""}
|
||||
>
|
||||
<Users className="h-3 w-3 mr-2" />
|
||||
Сотрудники
|
||||
<Users className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Сотрудники</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -209,15 +269,16 @@ export function Sidebar() {
|
||||
{user?.organization?.type === 'SELLER' && (
|
||||
<Button
|
||||
variant={isSuppliesActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
isSuppliesActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handleSuppliesClick}
|
||||
title={isCollapsed ? "Поставки" : ""}
|
||||
>
|
||||
<Truck className="h-3 w-3 mr-2" />
|
||||
Поставки
|
||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Поставки</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -225,41 +286,44 @@ export function Sidebar() {
|
||||
{user?.organization?.type === 'WHOLESALE' && (
|
||||
<Button
|
||||
variant={isWarehouseActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
isWarehouseActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handleWarehouseClick}
|
||||
title={isCollapsed ? "Склад" : ""}
|
||||
>
|
||||
<Warehouse className="h-3 w-3 mr-2" />
|
||||
Склад
|
||||
<Warehouse className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Склад</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={isSettingsActive ? "secondary" : "ghost"}
|
||||
className={`w-full justify-start text-left transition-all duration-200 h-8 text-xs ${
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-left transition-all duration-200 text-xs ${
|
||||
isSettingsActive
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
} cursor-pointer`}
|
||||
onClick={handleSettingsClick}
|
||||
title={isCollapsed ? "Настройки профиля" : ""}
|
||||
>
|
||||
<Settings className="h-3 w-3 mr-2" />
|
||||
Настройки профиля
|
||||
<Settings className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Настройки профиля</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Кнопка выхода */}
|
||||
<div className="flex-1 flex items-end">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-white/80 hover:bg-red-500/20 hover:text-red-300 cursor-pointer h-8 text-xs"
|
||||
className={`w-full ${isCollapsed ? 'justify-center px-2 h-9' : 'justify-start h-10'} text-white/80 hover:bg-red-500/20 hover:text-red-300 cursor-pointer text-xs transition-all duration-200`}
|
||||
onClick={logout}
|
||||
title={isCollapsed ? "Выйти" : ""}
|
||||
>
|
||||
<LogOut className="h-3 w-3 mr-2" />
|
||||
Выйти
|
||||
<LogOut className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Выйти</span>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Sidebar } from './sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import {
|
||||
User,
|
||||
Building2,
|
||||
@ -38,6 +39,7 @@ import { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
|
||||
export function UserSettings() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const { user } = useAuth()
|
||||
const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE)
|
||||
const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN)
|
||||
@ -552,7 +554,7 @@ export function UserSettings() {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Сообщения о сохранении */}
|
||||
{saveMessage && (
|
||||
|
@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { MarketProducts } from './market-products'
|
||||
import { MarketCategories } from './market-categories'
|
||||
import { MarketRequests } from './market-requests'
|
||||
@ -12,6 +13,7 @@ import { MarketBusiness } from './market-business'
|
||||
import { FavoritesDashboard } from '../favorites/favorites-dashboard'
|
||||
|
||||
export function MarketDashboard() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const [productsView, setProductsView] = useState<'categories' | 'products' | 'cart' | 'favorites'>('categories')
|
||||
const [selectedCategory, setSelectedCategory] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
@ -38,7 +40,7 @@ export function MarketDashboard() {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Основной контент с табами */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
@ -1,22 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react'
|
||||
import React, { useState } 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 { useSidebar } from '@/hooks/useSidebar'
|
||||
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,
|
||||
PanelLeftOpen,
|
||||
PanelLeftClose,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
@ -31,14 +26,9 @@ interface Organization {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type LeftPanelSize = 'compact' | 'normal' | 'wide' | 'hidden'
|
||||
|
||||
export function MessengerDashboard() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
|
||||
const [leftPanelSize, setLeftPanelSize] = useState<LeftPanelSize>('normal')
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(350)
|
||||
const resizeRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
||||
const counterparties = counterpartiesData?.myCounterparties || []
|
||||
@ -49,85 +39,13 @@ 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 (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
|
||||
<p className="text-white/70 text-sm">Общение с контрагентами</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
<MessengerEmptyState />
|
||||
@ -142,90 +60,38 @@ export function MessengerDashboard() {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Заголовок с управлением панелями */}
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Мессенджер</h1>
|
||||
<p className="text-white/70 text-sm">Общение с контрагентами</p>
|
||||
</div>
|
||||
|
||||
{/* Управление панелями */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={togglePanelVisibility}
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20 h-8"
|
||||
title={leftPanelSize === 'hidden' ? 'Показать список' : 'Скрыть список'}
|
||||
>
|
||||
{leftPanelSize === 'hidden' ? (
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{leftPanelSize !== 'hidden' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={togglePanelSize}
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20 h-8"
|
||||
title="Изменить размер списка"
|
||||
>
|
||||
{leftPanelSize === 'compact' ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : leftPanelSize === 'wide' ? (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Settings className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1 text-xs">
|
||||
{leftPanelSize === 'compact' ? 'Компакт' :
|
||||
leftPanelSize === 'wide' ? 'Широкий' : 'Обычный'}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex gap-4 h-full">
|
||||
<PanelGroup direction="horizontal" className="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` }}
|
||||
<Panel
|
||||
defaultSize={30}
|
||||
minSize={15}
|
||||
maxSize={50}
|
||||
className="pr-2"
|
||||
>
|
||||
<Card className="glass-card h-full overflow-hidden p-4">
|
||||
<MessengerConversations
|
||||
counterparties={counterparties}
|
||||
loading={counterpartiesLoading}
|
||||
selectedCounterparty={selectedCounterparty}
|
||||
onSelectCounterparty={handleSelectCounterparty}
|
||||
compact={leftPanelSize === 'compact'}
|
||||
compact={false}
|
||||
/>
|
||||
</Card>
|
||||
</Panel>
|
||||
|
||||
{/* Разделитель для изменения размера */}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PanelResizeHandle className="w-2 hover:bg-white/10 transition-colors relative group cursor-col-resize">
|
||||
<div className="absolute inset-y-0 left-1/2 transform -translate-x-1/2 w-1 bg-white/10 group-hover:bg-white/20 transition-colors" />
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-1 h-8 bg-white/30 rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Правая панель - чат */}
|
||||
<Card className="glass-card h-full overflow-hidden flex-1">
|
||||
<Panel defaultSize={70} className="pl-2">
|
||||
<Card className="glass-card h-full overflow-hidden">
|
||||
{selectedCounterparty && selectedCounterpartyData ? (
|
||||
<MessengerChat counterparty={selectedCounterpartyData} />
|
||||
) : (
|
||||
@ -238,19 +104,12 @@ export function MessengerDashboard() {
|
||||
<p className="text-white/40 text-sm">
|
||||
Начните беседу с одним из ваших контрагентов
|
||||
</p>
|
||||
{leftPanelSize === 'hidden' && (
|
||||
<Button
|
||||
onClick={togglePanelVisibility}
|
||||
className="mt-4 bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
Показать список контрагентов
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
@ -3,6 +3,7 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { MarketCounterparties } from '../market/market-counterparties'
|
||||
import { MarketFulfillment } from '../market/market-fulfillment'
|
||||
import { MarketSellers } from '../market/market-sellers'
|
||||
@ -10,10 +11,11 @@ import { MarketLogistics } from '../market/market-logistics'
|
||||
import { MarketWholesale } from '../market/market-wholesale'
|
||||
|
||||
export function PartnersDashboard() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Основной контент с табами */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
@ -2,15 +2,17 @@
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { ServicesTab } from './services-tab'
|
||||
import { SuppliesTab } from './supplies-tab'
|
||||
import { LogisticsTab } from './logistics-tab'
|
||||
|
||||
export function ServicesDashboard() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Основной контент с табами */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
@ -6,6 +6,7 @@ import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { ProductForm } from './product-form'
|
||||
import { ProductCard } from './product-card'
|
||||
import { GET_MY_PRODUCTS } from '@/graphql/queries'
|
||||
@ -34,6 +35,7 @@ interface Product {
|
||||
}
|
||||
|
||||
export function WarehouseDashboard() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@ -104,7 +106,7 @@ export function WarehouseDashboard() {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-56 px-6 py-4 overflow-hidden">
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Заголовок и поиск */}
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
@ -114,14 +116,6 @@ export function WarehouseDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => window.open('/admin', '_blank')}
|
||||
variant="outline"
|
||||
className="bg-white/10 hover:bg-white/20 text-white border-white/20 hover:border-white/30"
|
||||
>
|
||||
Управление категориями
|
||||
</Button>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
|
@ -137,6 +137,30 @@ const JSONScalar = new GraphQLScalarType({
|
||||
}
|
||||
})
|
||||
|
||||
// Скалярный тип для DateTime
|
||||
const DateTimeScalar = new GraphQLScalarType({
|
||||
name: 'DateTime',
|
||||
description: 'DateTime custom scalar type',
|
||||
serialize(value: unknown) {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString() // значение отправляется клиенту как ISO строка
|
||||
}
|
||||
return value
|
||||
},
|
||||
parseValue(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
return new Date(value) // значение получено от клиента, парсим как дату
|
||||
}
|
||||
return value
|
||||
},
|
||||
parseLiteral(ast) {
|
||||
if (ast.kind === Kind.STRING) {
|
||||
return new Date(ast.value) // AST значение как дата
|
||||
}
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function parseLiteral(ast: unknown): unknown {
|
||||
const astNode = ast as { kind: string; value?: unknown; fields?: unknown[]; values?: unknown[] }
|
||||
|
||||
@ -166,6 +190,7 @@ function parseLiteral(ast: unknown): unknown {
|
||||
|
||||
export const resolvers = {
|
||||
JSON: JSONScalar,
|
||||
DateTime: DateTimeScalar,
|
||||
|
||||
Query: {
|
||||
me: async (_: unknown, __: unknown, context: Context) => {
|
||||
@ -643,7 +668,7 @@ export const resolvers = {
|
||||
|
||||
// Все категории
|
||||
categories: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user) {
|
||||
if (!context.user && !context.admin) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' }
|
||||
})
|
||||
@ -2735,7 +2760,7 @@ export const resolvers = {
|
||||
|
||||
// Создать категорию
|
||||
createCategory: async (_: unknown, args: { input: { name: string } }, context: Context) => {
|
||||
if (!context.user) {
|
||||
if (!context.user && !context.admin) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' }
|
||||
})
|
||||
@ -2776,7 +2801,7 @@ export const resolvers = {
|
||||
|
||||
// Обновить категорию
|
||||
updateCategory: async (_: unknown, args: { id: string; input: { name: string } }, context: Context) => {
|
||||
if (!context.user) {
|
||||
if (!context.user && !context.admin) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' }
|
||||
})
|
||||
@ -2832,7 +2857,7 @@ export const resolvers = {
|
||||
|
||||
// Удалить категорию
|
||||
deleteCategory: async (_: unknown, args: { id: string }, context: Context) => {
|
||||
if (!context.user) {
|
||||
if (!context.user && !context.admin) {
|
||||
throw new GraphQLError('Требуется авторизация', {
|
||||
extensions: { code: 'UNAUTHENTICATED' }
|
||||
})
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { gql } from 'graphql-tag'
|
||||
|
||||
export const typeDefs = gql`
|
||||
scalar DateTime
|
||||
|
||||
type Query {
|
||||
me: User
|
||||
organization(id: ID!): Organization
|
||||
@ -150,8 +152,8 @@ export const typeDefs = gql`
|
||||
avatar: String
|
||||
managerName: String
|
||||
organization: Organization
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type Organization {
|
||||
@ -163,12 +165,12 @@ export const typeDefs = gql`
|
||||
address: String
|
||||
addressFull: String
|
||||
ogrn: String
|
||||
ogrnDate: String
|
||||
ogrnDate: DateTime
|
||||
type: OrganizationType!
|
||||
status: String
|
||||
actualityDate: String
|
||||
registrationDate: String
|
||||
liquidationDate: String
|
||||
actualityDate: DateTime
|
||||
registrationDate: DateTime
|
||||
liquidationDate: DateTime
|
||||
managementName: String
|
||||
managementPost: String
|
||||
opfCode: String
|
||||
@ -189,8 +191,8 @@ export const typeDefs = gql`
|
||||
isCurrentUser: Boolean
|
||||
hasOutgoingRequest: Boolean
|
||||
hasIncomingRequest: Boolean
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type ApiKey {
|
||||
@ -198,8 +200,8 @@ export const typeDefs = gql`
|
||||
marketplace: MarketplaceType!
|
||||
isActive: Boolean!
|
||||
validationData: JSON
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
# Входные типы для мутаций
|
||||
@ -312,8 +314,8 @@ export const typeDefs = gql`
|
||||
message: String
|
||||
sender: Organization!
|
||||
receiver: Organization!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type CounterpartyRequestResponse {
|
||||
@ -337,8 +339,8 @@ export const typeDefs = gql`
|
||||
senderOrganization: Organization!
|
||||
receiverOrganization: Organization!
|
||||
isRead: Boolean!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
@ -353,7 +355,7 @@ export const typeDefs = gql`
|
||||
counterparty: Organization!
|
||||
lastMessage: Message
|
||||
unreadCount: Int!
|
||||
updatedAt: String!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type MessageResponse {
|
||||
@ -369,8 +371,8 @@ export const typeDefs = gql`
|
||||
description: String
|
||||
price: Float!
|
||||
imageUrl: String
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
organization: Organization!
|
||||
}
|
||||
|
||||
@ -394,8 +396,8 @@ export const typeDefs = gql`
|
||||
description: String
|
||||
price: Float!
|
||||
imageUrl: String
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
organization: Organization!
|
||||
}
|
||||
|
||||
@ -420,8 +422,8 @@ export const typeDefs = gql`
|
||||
priceUnder1m3: Float!
|
||||
priceOver1m3: Float!
|
||||
description: String
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
organization: Organization!
|
||||
}
|
||||
|
||||
@ -443,8 +445,8 @@ export const typeDefs = gql`
|
||||
type Category {
|
||||
id: ID!
|
||||
name: String!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
# Типы для товаров оптовика
|
||||
@ -465,8 +467,8 @@ export const typeDefs = gql`
|
||||
images: [String!]!
|
||||
mainImage: String
|
||||
isActive: Boolean!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
organization: Organization!
|
||||
}
|
||||
|
||||
@ -510,8 +512,8 @@ export const typeDefs = gql`
|
||||
items: [CartItem!]!
|
||||
totalPrice: Float!
|
||||
totalItems: Int!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
organization: Organization!
|
||||
}
|
||||
|
||||
@ -522,8 +524,8 @@ export const typeDefs = gql`
|
||||
totalPrice: Float!
|
||||
isAvailable: Boolean!
|
||||
availableQuantity: Int!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type CartResponse {
|
||||
@ -545,17 +547,17 @@ export const typeDefs = gql`
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
middleName: String
|
||||
birthDate: String
|
||||
birthDate: DateTime
|
||||
avatar: String
|
||||
passportPhoto: String
|
||||
passportSeries: String
|
||||
passportNumber: String
|
||||
passportIssued: String
|
||||
passportDate: String
|
||||
passportDate: DateTime
|
||||
address: String
|
||||
position: String!
|
||||
department: String
|
||||
hireDate: String!
|
||||
hireDate: DateTime!
|
||||
salary: Float
|
||||
status: EmployeeStatus!
|
||||
phone: String!
|
||||
@ -566,8 +568,8 @@ export const typeDefs = gql`
|
||||
emergencyPhone: String
|
||||
scheduleRecords: [EmployeeSchedule!]!
|
||||
organization: Organization!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum EmployeeStatus {
|
||||
@ -579,13 +581,13 @@ export const typeDefs = gql`
|
||||
|
||||
type EmployeeSchedule {
|
||||
id: ID!
|
||||
date: String!
|
||||
date: DateTime!
|
||||
status: ScheduleStatus!
|
||||
hoursWorked: Float
|
||||
notes: String
|
||||
employee: Employee!
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum ScheduleStatus {
|
||||
@ -600,17 +602,17 @@ export const typeDefs = gql`
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
middleName: String
|
||||
birthDate: String
|
||||
birthDate: DateTime
|
||||
avatar: String
|
||||
passportPhoto: String
|
||||
passportSeries: String
|
||||
passportNumber: String
|
||||
passportIssued: String
|
||||
passportDate: String
|
||||
passportDate: DateTime
|
||||
address: String
|
||||
position: String!
|
||||
department: String
|
||||
hireDate: String!
|
||||
hireDate: DateTime!
|
||||
salary: Float
|
||||
phone: String!
|
||||
email: String
|
||||
@ -624,17 +626,17 @@ export const typeDefs = gql`
|
||||
firstName: String
|
||||
lastName: String
|
||||
middleName: String
|
||||
birthDate: String
|
||||
birthDate: DateTime
|
||||
avatar: String
|
||||
passportPhoto: String
|
||||
passportSeries: String
|
||||
passportNumber: String
|
||||
passportIssued: String
|
||||
passportDate: String
|
||||
passportDate: DateTime
|
||||
address: String
|
||||
position: String
|
||||
department: String
|
||||
hireDate: String
|
||||
hireDate: DateTime
|
||||
salary: Float
|
||||
status: EmployeeStatus
|
||||
phone: String
|
||||
@ -647,7 +649,7 @@ export const typeDefs = gql`
|
||||
|
||||
input UpdateScheduleInput {
|
||||
employeeId: ID!
|
||||
date: String!
|
||||
date: DateTime!
|
||||
status: ScheduleStatus!
|
||||
hoursWorked: Float
|
||||
notes: String
|
||||
@ -675,8 +677,8 @@ export const typeDefs = gql`
|
||||
email: String
|
||||
isActive: Boolean!
|
||||
lastLogin: String
|
||||
createdAt: String!
|
||||
updatedAt: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type AdminAuthResponse {
|
||||
|
46
src/hooks/useSidebar.tsx
Normal file
46
src/hooks/useSidebar.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
toggleSidebar: () => void
|
||||
getSidebarMargin: () => string
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextType | undefined>(undefined)
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}
|
||||
|
||||
const getSidebarMargin = () => {
|
||||
// Учитываем отступ слева (left-4) + ширина сайдбара + дополнительный отступ
|
||||
return isCollapsed ? 'ml-20' : 'ml-80'
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
toggleSidebar,
|
||||
getSidebarMargin
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const context = useContext(SidebarContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider')
|
||||
}
|
||||
return context
|
||||
}
|
Reference in New Issue
Block a user