Удален файл с тестовым заданием по системе управления сотрудниками. Обновлены зависимости в package.json и package-lock.json, добавлен новый пакет react-resizable-panels. Внесены изменения в компоненты для улучшения работы боковой панели и отображения дат. Добавлены новые функции для обработки дат в формате DateTime в GraphQL.

This commit is contained in:
Bivekich
2025-07-20 22:50:21 +03:00
parent 8d57fcd748
commit cc1f9d8473
16 changed files with 354 additions and 696 deletions

View File

@ -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
View File

@ -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",

View File

@ -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"
},

View File

@ -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}>
{children}
<SidebarProvider>
{children}
</SidebarProvider>
</ApolloProvider>
)
}

View File

@ -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">

View File

@ -96,13 +96,21 @@ export function UsersSection() {
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
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) => {

View File

@ -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,96 +102,149 @@ 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">
<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">
{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-sm font-semibold">
{getInitials()}
</AvatarFallback>
</Avatar>
<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()}>
{getOrganizationName()}
</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()}
{!isCollapsed ? (
// Развернутое состояние
<div className="flex items-center space-x-3">
<div className="relative flex-shrink-0">
<Avatar className="h-12 w-12 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-sm font-semibold">
{getInitials()}
</AvatarFallback>
</Avatar>
<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 mb-1 break-words" title={getOrganizationName()}>
{getOrganizationName()}
</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 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>

View File

@ -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 && (

View File

@ -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">

View File

@ -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,115 +60,56 @@ 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` }}
>
<MessengerConversations
counterparties={counterparties}
loading={counterpartiesLoading}
selectedCounterparty={selectedCounterparty}
onSelectCounterparty={handleSelectCounterparty}
compact={leftPanelSize === 'compact'}
/>
</Card>
<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={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">
{selectedCounterparty && selectedCounterpartyData ? (
<MessengerChat counterparty={selectedCounterpartyData} />
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<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" />
<Panel defaultSize={70} className="pl-2">
<Card className="glass-card h-full overflow-hidden">
{selectedCounterparty && selectedCounterpartyData ? (
<MessengerChat counterparty={selectedCounterpartyData} />
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<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>
<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>
)}
</Card>
</div>
)}
</Card>
</Panel>
</PanelGroup>
</div>
</div>
</main>

View File

@ -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">

View File

@ -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">

View File

@ -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

View File

@ -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' }
})

View File

@ -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
View 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
}