Удален файл с тестовым заданием по системе управления сотрудниками. Обновлены зависимости в 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": "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",

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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