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