Добавлены модели и функциональность для управления сотрудниками, включая создание, обновление и удаление сотрудников через GraphQL. Обновлены компоненты для отображения списка сотрудников и их расписания, улучшен интерфейс взаимодействия с пользователем. Реализованы функции экспорта отчетов в CSV и TXT форматах, добавлены новые интерфейсы и типы данных для сотрудников.

This commit is contained in:
Bivekich
2025-07-17 23:55:11 +03:00
parent 3e2a03da8c
commit d361364716
13 changed files with 3444 additions and 428 deletions

View File

@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
@ -10,29 +7,21 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// Модель пользователя
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
phone String @unique phone String @unique
avatar String? // URL аватара в S3 avatar String?
managerName String? // Имя управляющего managerName String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Связь с организацией
organization Organization? @relation(fields: [organizationId], references: [id])
organizationId String? organizationId String?
// SMS коды для авторизации
smsCodes SmsCode[]
// Отправленные сообщения
sentMessages Message[] @relation("SentMessages") sentMessages Message[] @relation("SentMessages")
smsCodes SmsCode[]
organization Organization? @relation(fields: [organizationId], references: [id])
@@map("users") @@map("users")
} }
// Модель для SMS кодов
model SmsCode { model SmsCode {
id String @id @default(cuid()) id String @id @default(cuid())
code String code String
@ -42,95 +31,62 @@ model SmsCode {
attempts Int @default(0) attempts Int @default(0)
maxAttempts Int @default(3) maxAttempts Int @default(3)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Связь с пользователем
user User? @relation(fields: [userId], references: [id])
userId String? userId String?
user User? @relation(fields: [userId], references: [id])
@@map("sms_codes") @@map("sms_codes")
} }
// Модель организации
model Organization { model Organization {
id String @id @default(cuid()) id String @id @default(cuid())
inn String @unique inn String @unique
kpp String? // КПП kpp String?
name String? // Краткое наименование name String?
fullName String? // Полное наименование fullName String?
ogrn String? // ОГРН организации ogrn String?
ogrnDate DateTime? // Дата выдачи ОГРН ogrnDate DateTime?
type OrganizationType type OrganizationType
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
address String?
// Адрес организации addressFull String?
address String? // Адрес одной строкой status String?
addressFull String? // Полный адрес с индексом actualityDate DateTime?
registrationDate DateTime?
// Статус организации liquidationDate DateTime?
status String? // ACTIVE, LIQUIDATED и т.д. managementName String?
actualityDate DateTime? // Дата последних изменений managementPost String?
registrationDate DateTime? // Дата регистрации opfCode String?
liquidationDate DateTime? // Дата ликвидации opfFull String?
opfShort String?
// Руководитель okato String?
managementName String? // ФИО или наименование руководителя oktmo String?
managementPost String? // Должность руководителя okpo String?
okved String?
// ОПФ (Организационно-правовая форма) phones Json?
opfCode String? // Код ОКОПФ emails Json?
opfFull String? // Полное название ОПФ employeeCount Int?
opfShort String? // Краткое название ОПФ revenue BigInt?
taxSystem String?
// Коды статистики
okato String? // Код ОКАТО
oktmo String? // Код ОКТМО
okpo String? // Код ОКПО
okved String? // Основной код ОКВЭД
// Контакты
phones Json? // Массив телефонов
emails Json? // Массив email адресов
// Финансовые данные
employeeCount Int? // Численность сотрудников
revenue BigInt? // Выручка
taxSystem String? // Система налогообложения
// Полные данные из DaData (для полноты)
dadataData Json? dadataData Json?
// Связи
users User[]
apiKeys ApiKey[] apiKeys ApiKey[]
carts Cart?
// Связи контрагентов
sentRequests CounterpartyRequest[] @relation("SentRequests")
receivedRequests CounterpartyRequest[] @relation("ReceivedRequests")
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
counterpartyOf Counterparty[] @relation("CounterpartyOf") counterpartyOf Counterparty[] @relation("CounterpartyOf")
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
// Сообщения receivedRequests CounterpartyRequest[] @relation("ReceivedRequests")
sentMessages Message[] @relation("SentMessages") sentRequests CounterpartyRequest[] @relation("SentRequests")
employees Employee[]
favorites Favorites[]
receivedMessages Message[] @relation("ReceivedMessages") receivedMessages Message[] @relation("ReceivedMessages")
sentMessages Message[] @relation("SentMessages")
// Услуги и расходники (только для фулфилмент центров) products Product[]
services Service[] services Service[]
supplies Supply[] supplies Supply[]
users User[]
// Товары (только для оптовиков)
products Product[]
// Корзины
carts Cart[]
// Избранные товары
favorites Favorites[]
@@map("organizations") @@map("organizations")
} }
// Модель для API ключей маркетплейсов
model ApiKey { model ApiKey {
id String @id @default(cuid()) id String @id @default(cuid())
marketplace MarketplaceType marketplace MarketplaceType
@ -138,110 +94,239 @@ model ApiKey {
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Данные для валидации (например, информация о продавце)
validationData Json? validationData Json?
// Связь с организацией
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
@@unique([organizationId, marketplace]) @@unique([organizationId, marketplace])
@@map("api_keys") @@map("api_keys")
} }
// Тип организации
enum OrganizationType {
FULFILLMENT // Фулфилмент
SELLER // Селлер
LOGIST // Логистика
WHOLESALE // Оптовик
}
// Тип маркетплейса
enum MarketplaceType {
WILDBERRIES
OZON
}
// Модель для заявок на добавление в контрагенты
model CounterpartyRequest { model CounterpartyRequest {
id String @id @default(cuid()) id String @id @default(cuid())
status CounterpartyRequestStatus @default(PENDING) status CounterpartyRequestStatus @default(PENDING)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Кто отправил заявку
sender Organization @relation("SentRequests", fields: [senderId], references: [id])
senderId String senderId String
// Кому отправили заявку
receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id])
receiverId String receiverId String
// Комментарий к заявке
message String? message String?
receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id])
sender Organization @relation("SentRequests", fields: [senderId], references: [id])
@@unique([senderId, receiverId]) @@unique([senderId, receiverId])
@@map("counterparty_requests") @@map("counterparty_requests")
} }
// Модель для связей контрагентов
model Counterparty { model Counterparty {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Основная организация
organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id])
organizationId String organizationId String
// Контрагент
counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id])
counterpartyId String counterpartyId String
counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id])
organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id])
@@unique([organizationId, counterpartyId]) @@unique([organizationId, counterpartyId])
@@map("counterparties") @@map("counterparties")
} }
// Статус заявки на добавление в контрагенты
enum CounterpartyRequestStatus {
PENDING // Ожидает ответа
ACCEPTED // Принята
REJECTED // Отклонена
CANCELLED // Отменена отправителем
}
// Модель сообщений в мессенджере
model Message { model Message {
id String @id @default(cuid()) id String @id @default(cuid())
content String? // Текст сообщения (nullable для медиа) content String?
type MessageType @default(TEXT) type MessageType @default(TEXT)
voiceUrl String? // URL голосового файла в S3 voiceUrl String?
voiceDuration Int? // Длительность голосового сообщения в секундах voiceDuration Int?
fileUrl String? // URL файла/изображения в S3 fileUrl String?
fileName String? // Оригинальное имя файла fileName String?
fileSize Int? // Размер файла в байтах fileSize Int?
fileType String? // MIME тип файла fileType String?
isRead Boolean @default(false) isRead Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Отправитель
sender User @relation("SentMessages", fields: [senderId], references: [id])
senderId String senderId String
senderOrganization Organization @relation("SentMessages", fields: [senderOrganizationId], references: [id])
senderOrganizationId String senderOrganizationId String
// Получатель
receiverOrganization Organization @relation("ReceivedMessages", fields: [receiverOrganizationId], references: [id])
receiverOrganizationId String receiverOrganizationId String
receiverOrganization Organization @relation("ReceivedMessages", fields: [receiverOrganizationId], references: [id])
sender User @relation("SentMessages", fields: [senderId], references: [id])
senderOrganization Organization @relation("SentMessages", fields: [senderOrganizationId], references: [id])
@@index([senderOrganizationId, receiverOrganizationId, createdAt]) @@index([senderOrganizationId, receiverOrganizationId, createdAt])
@@index([receiverOrganizationId, isRead]) @@index([receiverOrganizationId, isRead])
@@map("messages") @@map("messages")
} }
// Типы сообщений model Service {
id String @id @default(cuid())
name String
description String?
price Decimal @db.Decimal(10, 2)
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@map("services")
}
model Supply {
id String @id @default(cuid())
name String
description String?
price Decimal @db.Decimal(10, 2)
quantity Int @default(0)
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@map("supplies")
}
model Category {
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
products Product[]
@@map("categories")
}
model Product {
id String @id @default(cuid())
name String
article String
description String?
price Decimal @db.Decimal(12, 2)
quantity Int @default(0)
categoryId String?
brand String?
color String?
size String?
weight Decimal? @db.Decimal(8, 3)
dimensions String?
material String?
images Json @default("[]")
mainImage String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organizationId String
cartItems CartItem[]
favorites Favorites[]
category Category? @relation(fields: [categoryId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([organizationId, article])
@@map("products")
}
model Cart {
id String @id @default(cuid())
organizationId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items CartItem[]
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@map("carts")
}
model CartItem {
id String @id @default(cuid())
cartId String
productId String
quantity Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([cartId, productId])
@@map("cart_items")
}
model Favorites {
id String @id @default(cuid())
organizationId String
productId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([organizationId, productId])
@@map("favorites")
}
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], onDelete: Cascade)
@@map("employees")
}
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], onDelete: Cascade)
@@unique([employeeId, date])
@@map("employee_schedules")
}
enum OrganizationType {
FULFILLMENT
SELLER
LOGIST
WHOLESALE
}
enum MarketplaceType {
WILDBERRIES
OZON
}
enum CounterpartyRequestStatus {
PENDING
ACCEPTED
REJECTED
CANCELLED
}
enum MessageType { enum MessageType {
TEXT TEXT
VOICE VOICE
@ -249,164 +334,17 @@ enum MessageType {
FILE FILE
} }
// Модель услуг (для фулфилмент центров) enum EmployeeStatus {
model Service { ACTIVE
id String @id @default(cuid()) VACATION
name String SICK
description String? FIRED
price Decimal @db.Decimal(10,2) // Цена за единицу
imageUrl String? // URL фотографии в S3
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Связь с организацией
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
@@map("services")
} }
// Модель расходников (для фулфилмент центров) enum ScheduleStatus {
model Supply { WORK
id String @id @default(cuid()) WEEKEND
name String VACATION
description String? SICK
price Decimal @db.Decimal(10,2) // Цена за единицу ABSENT
quantity Int @default(0) // Количество в наличии
imageUrl String? // URL фотографии в S3
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Связь с организацией
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
@@map("supplies")
}
// Модель категорий товаров
model Category {
id String @id @default(cuid())
name String @unique // Название категории
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Связь с товарами
products Product[]
@@map("categories")
}
// Модель товаров (для оптовиков)
model Product {
id String @id @default(cuid())
// Основные поля
name String // Название товара
article String // Артикул/номер записи
description String? // Описание
// Цена и количество
price Decimal @db.Decimal(12,2) // Цена за единицу
quantity Int @default(0) // Количество в наличии
// Основные характеристики
category Category? @relation(fields: [categoryId], references: [id])
categoryId String? // ID категории
brand String? // Бренд
// Дополнительные характеристики (необязательные)
color String? // Цвет
size String? // Размер
weight Decimal? @db.Decimal(8,3) // Вес в кг
dimensions String? // Габариты (ДxШxВ)
material String? // Материал
// Изображения (JSON массив URL-ов в S3)
images Json @default("[]") // Массив URL изображений
mainImage String? // URL главного изображения
// Статус товара
isActive Boolean @default(true) // Активен ли товар
// Временные метки
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Связь с организацией (только оптовики)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
// Связь с элементами корзины
cartItems CartItem[]
// Избранные товары
favorites Favorites[]
// Уникальность артикула в рамках организации
@@unique([organizationId, article])
@@map("products")
}
// Модель корзины
model Cart {
id String @id @default(cuid())
// Связь с организацией (только покупатель может иметь корзину)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String @unique // У каждой организации может быть только одна корзина
// Элементы корзины
items CartItem[]
// Временные метки
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("carts")
}
// Модель элемента корзины
model CartItem {
id String @id @default(cuid())
// Связь с корзиной
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
cartId String
// Связь с товаром
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
// Количество товара в корзине
quantity Int @default(1)
// Временные метки
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Уникальность: один товар может быть только один раз в корзине
@@unique([cartId, productId])
@@map("cart_items")
}
// Модель избранных товаров
model Favorites {
id String @id @default(cuid())
// Связь с организацией (пользователь может добавлять товары в избранное)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organizationId String
// Связь с товаром
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
// Временные метки
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Уникальность: один товар может быть только один раз в избранном у организации
@@unique([organizationId, productId])
@@map("favorites")
} }

View File

@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server'
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const s3Client = new S3Client({
region: 'ru-1',
endpoint: 'https://s3.twcstorage.ru',
credentials: {
accessKeyId: 'I6XD2OR7YO2ZN6L6Z629',
secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ'
},
forcePathStyle: true
})
const BUCKET_NAME = '617774af-sfera'
// Разрешенные типы изображений
const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif'
]
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
const documentType = formData.get('documentType') as string // 'passport' | 'other'
if (!file) {
return NextResponse.json(
{ error: 'File is required' },
{ status: 400 }
)
}
if (!documentType) {
return NextResponse.json(
{ error: 'Document type is required' },
{ status: 400 }
)
}
// Проверяем, что файл не пустой
if (file.size === 0) {
return NextResponse.json(
{ error: 'File is empty' },
{ status: 400 }
)
}
// Проверяем имя файла
if (!file.name || file.name.trim().length === 0) {
return NextResponse.json(
{ error: 'Invalid file name' },
{ status: 400 }
)
}
// Проверяем тип файла - только изображения
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: `File type ${file.type} is not allowed. Only images are supported.` },
{ status: 400 }
)
}
// Ограничиваем размер файла - 10MB для изображений
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
return NextResponse.json(
{ error: `File size must be less than 10MB` },
{ status: 400 }
)
}
// Генерируем уникальное имя файла
const timestamp = Date.now()
const safeFileName = file.name
.replace(/[^\w\s.-]/g, '_')
.replace(/\s+/g, '_')
.replace(/_{2,}/g, '_')
.toLowerCase()
// Определяем папку в зависимости от типа документа
const folder = `employee-documents/${documentType}`
const key = `${folder}/${timestamp}-${safeFileName}`
// Конвертируем файл в Buffer
const buffer = Buffer.from(await file.arrayBuffer())
// Подготавливаем метаданные
const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_')
const metadata = {
originalname: cleanOriginalName,
documenttype: documentType,
uploadtype: 'employee-document'
}
// Загружаем в S3
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: file.type,
ACL: 'public-read',
Metadata: metadata
})
await s3Client.send(command)
// Возвращаем URL файла и метаданные
const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}`
return NextResponse.json({
success: true,
url,
key,
originalName: file.name,
size: file.size,
type: file.type,
documentType
})
} catch (error) {
console.error('Error uploading employee document:', error)
let errorMessage = 'Failed to upload document'
if (error instanceof Error) {
if (error.message.includes('Invalid character in header')) {
errorMessage = 'Invalid characters in file name or metadata'
} else if (error.message.includes('AccessDenied')) {
errorMessage = 'Access denied to storage'
} else if (error.message.includes('NoSuchBucket')) {
errorMessage = 'Storage bucket not found'
} else {
errorMessage = error.message
}
}
return NextResponse.json(
{ error: errorMessage, success: false },
{ status: 500 }
)
}
}

View File

@ -1,5 +1,5 @@
import { AuthGuard } from "@/components/auth-guard" import { AuthGuard } from '@/components/auth-guard'
import { EmployeesDashboard } from "@/components/employees/employees-dashboard" import { EmployeesDashboard } from '@/components/employees/employees-dashboard'
export default function EmployeesPage() { export default function EmployeesPage() {
return ( return (

View File

@ -0,0 +1,559 @@
"use client"
import { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Camera,
User,
X,
Save,
UserPen,
Phone,
Mail,
Briefcase,
DollarSign,
FileText,
MessageCircle
} from 'lucide-react'
import { toast } from 'sonner'
interface Employee {
id: string
firstName: string
lastName: string
middleName?: string
position: string
phone: string
email?: string
avatar?: string
hireDate: string
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
salary?: number
address?: string
birthDate?: string
passportSeries?: string
passportNumber?: string
passportIssued?: string
passportDate?: string
emergencyContact?: string
emergencyPhone?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
createdAt: string
updatedAt: string
}
interface EmployeeEditInlineFormProps {
employee: Employee
onSave: (employeeData: {
firstName: string
lastName: string
middleName?: string
birthDate?: string
phone: string
email?: string
position: string
salary?: number
avatar?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
}) => void
onCancel: () => void
isLoading?: boolean
}
export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = false }: EmployeeEditInlineFormProps) {
// Функция для форматирования даты из ISO в YYYY-MM-DD
const formatDateForInput = (dateString?: string) => {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
return date.toISOString().split('T')[0];
};
const [formData, setFormData] = useState({
firstName: employee.firstName || '',
lastName: employee.lastName || '',
middleName: employee.middleName || '',
birthDate: formatDateForInput(employee.birthDate) || '',
phone: employee.phone || '',
telegram: employee.telegram || '',
whatsapp: employee.whatsapp || '',
email: employee.email || '',
position: employee.position || '',
salary: employee.salary || 0,
avatar: employee.avatar || '',
passportPhoto: employee.passportPhoto || ''
})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
const [showPassportPreview, setShowPassportPreview] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(null)
const handleInputChange = (field: string, value: string | number) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
const handleFileUpload = async (file: File, type: 'avatar' | 'passport') => {
const setLoading = type === 'avatar' ? setIsUploadingAvatar : setIsUploadingPassport
setLoading(true)
try {
const formDataUpload = new FormData()
formDataUpload.append('file', file)
let endpoint: string
if (type === 'avatar') {
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
endpoint = '/api/upload-avatar'
} else {
formDataUpload.append('documentType', 'passport')
endpoint = '/api/upload-employee-document'
}
const response = await fetch(endpoint, {
method: 'POST',
body: formDataUpload
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Ошибка загрузки ${type === 'avatar' ? 'аватара' : 'паспорта'}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Неизвестная ошибка при загрузке')
}
setFormData(prev => ({
...prev,
[type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url
}))
toast.success(`${type === 'avatar' ? 'Фото' : 'Паспорт'} успешно загружен`)
} catch (error) {
console.error(`Error uploading ${type}:`, error)
const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'фото' : 'паспорта'}`
toast.error(errorMessage)
} finally {
setLoading(false)
}
}
const formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Валидация обязательных полей
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
return
}
// Подготавливаем данные для отправки
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName || undefined,
birthDate: formData.birthDate || undefined,
phone: formData.phone,
email: formData.email || undefined,
position: formData.position,
salary: formData.salary || undefined,
avatar: formData.avatar || undefined,
telegram: formData.telegram || undefined,
whatsapp: formData.whatsapp || undefined,
passportPhoto: formData.passportPhoto || undefined
}
onSave(employeeData)
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
return `${first}${last}`
}
return (
<Card className="glass-card mb-6">
<CardHeader className="pb-4">
<CardTitle className="text-white text-xl flex items-center gap-3">
<UserPen className="h-6 w-6 text-purple-400" />
Редактировать сотрудника: {employee.firstName} {employee.lastName}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Фотографии */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Фото сотрудника */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<Camera className="h-4 w-4" />
Фото сотрудника
</Label>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20 ring-2 ring-white/20">
{formData.avatar ? (
<AvatarImage
src={formData.avatar}
alt="Фото сотрудника"
onError={(e) => {
console.error('Ошибка загрузки аватара:', formData.avatar);
e.currentTarget.style.display = 'none';
}}
onLoad={() => console.log('Аватар загружен успешно:', formData.avatar)}
/>
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => avatarInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Изменить фото'}
</Button>
{formData.avatar && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, avatar: '' }))}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-2" />
Удалить
</Button>
)}
</div>
</div>
</div>
{/* Фото паспорта */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Фото паспорта
</Label>
<div className="space-y-3">
{formData.passportPhoto ? (
<div className="relative">
<img
src={formData.passportPhoto}
alt="Паспорт"
className="w-full h-auto max-h-48 object-contain rounded-lg border border-white/20 bg-white/5 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPassportPreview(true)}
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, passportPhoto: '' }))}
className="absolute top-2 right-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
Нажмите для увеличения
</div>
</div>
) : (
<div className="h-48 border-2 border-dashed border-white/20 rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="h-8 w-8 text-white/40 mx-auto mb-2" />
<p className="text-white/60 text-sm">Паспорт не загружен</p>
<p className="text-white/40 text-xs mt-1">Рекомендуемый формат: JPG, PNG</p>
</div>
</div>
)}
<input
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => passportInputRef.current?.click()}
disabled={isUploadingPassport}
className="w-full glass-secondary text-white hover:text-white"
>
<FileText className="h-4 w-4 mr-2" />
{isUploadingPassport ? 'Загрузка...' : 'Загрузить паспорт'}
</Button>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Основная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<User className="h-4 w-4" />
Личные данные
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Имя <span className="text-red-400">*</span>
</Label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Фамилия <span className="text-red-400">*</span>
</Label>
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Отчество</Label>
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Дата рождения
</Label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="glass-input text-white"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Контактная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Phone className="h-4 w-4" />
Контактная информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Телефон <span className="text-red-400">*</span>
</Label>
<Input
value={formData.phone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('phone', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
Telegram
</Label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
WhatsApp
</Label>
<Input
value={formData.whatsapp}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('whatsapp', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<Mail className="h-3 w-3" />
Email
</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Рабочая информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Рабочая информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Должность <span className="text-red-400">*</span>
</Label>
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<DollarSign className="h-3 w-3" />
Зарплата
</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
placeholder="80000"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300"
>
<X className="h-4 w-4 mr-2" />
Отмена
</Button>
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
{isLoading ? 'Сохранение...' : (
<>
<Save className="h-4 w-4 mr-2" />
Сохранить изменения
</>
)}
</Button>
</div>
</form>
</CardContent>
{/* Превью паспорта */}
<Dialog open={showPassportPreview} onOpenChange={setShowPassportPreview}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden glass-card">
<DialogHeader>
<DialogTitle className="text-white">Фото паспорта</DialogTitle>
</DialogHeader>
<div className="flex justify-center">
<img
src={formData.passportPhoto}
alt="Паспорт"
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@ -0,0 +1,453 @@
"use client"
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Upload, X, User, Camera } from 'lucide-react'
import { toast } from 'sonner'
interface Employee {
id: string
firstName: string
lastName: string
middleName?: string
position: string
phone: string
email?: string
avatar?: string
hireDate: string
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
salary?: number
address?: string
birthDate?: string
passportSeries?: string
passportNumber?: string
passportIssued?: string
passportDate?: string
emergencyContact?: string
emergencyPhone?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
createdAt: string
updatedAt: string
}
interface EmployeeFormProps {
employee?: Employee | null
onSave: (employeeData: Partial<Employee>) => void
onCancel: () => void
}
export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) {
const [formData, setFormData] = useState({
firstName: employee?.firstName || '',
lastName: employee?.lastName || '',
middleName: employee?.middleName || '',
position: employee?.position || '',
phone: employee?.phone || '',
email: employee?.email || '',
avatar: employee?.avatar || '',
hireDate: employee?.hireDate || new Date().toISOString().split('T')[0],
status: employee?.status || 'ACTIVE' as const,
salary: employee?.salary || 0,
address: employee?.address || '',
birthDate: employee?.birthDate || '',
passportSeries: employee?.passportSeries || '',
passportNumber: employee?.passportNumber || '',
passportIssued: employee?.passportIssued || '',
passportDate: employee?.passportDate || '',
emergencyContact: employee?.emergencyContact || '',
emergencyPhone: employee?.emergencyPhone || ''
})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [loading, setLoading] = useState(false)
const handleInputChange = (field: string, value: string | number) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
const handleAvatarUpload = async (file: File) => {
setIsUploadingAvatar(true)
try {
const formDataUpload = new FormData()
formDataUpload.append('file', file)
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
const response = await fetch('/api/upload-avatar', {
method: 'POST',
body: formDataUpload
})
if (!response.ok) {
throw new Error('Ошибка загрузки аватара')
}
const result = await response.json()
setFormData(prev => ({
...prev,
avatar: result.url
}))
toast.success('Аватар успешно загружен')
} catch (error) {
console.error('Error uploading avatar:', error)
toast.error('Ошибка при загрузке аватара')
} finally {
setIsUploadingAvatar(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
// Валидация
if (!formData.firstName || !formData.lastName || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
setLoading(false)
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
setLoading(false)
return
}
if (formData.phone && !/^[\+]?[1-9][\d]{0,15}$/.test(formData.phone.replace(/\s/g, ''))) {
toast.error('Введите корректный номер телефона')
setLoading(false)
return
}
try {
// Для создания/обновления отправляем только нужные поля
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName,
position: formData.position,
phone: formData.phone,
email: formData.email || undefined,
avatar: formData.avatar || undefined,
hireDate: formData.hireDate,
salary: formData.salary || undefined,
address: formData.address || undefined,
birthDate: formData.birthDate || undefined,
passportSeries: formData.passportSeries || undefined,
passportNumber: formData.passportNumber || undefined,
passportIssued: formData.passportIssued || undefined,
passportDate: formData.passportDate || undefined,
emergencyContact: formData.emergencyContact || undefined,
emergencyPhone: formData.emergencyPhone || undefined
}
onSave(employeeData)
toast.success(employee ? 'Сотрудник успешно обновлен' : 'Сотрудник успешно добавлен')
} catch (error) {
console.error('Error saving employee:', error)
toast.error('Ошибка при сохранении данных сотрудника')
} finally {
setLoading(false)
}
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
return `${first}${last}`
}
const formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Фото и основная информация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Личные данные</h3>
{/* Аватар */}
<div className="flex items-start gap-6 mb-6">
<div className="flex flex-col items-center gap-3">
<Avatar className="h-24 w-24 ring-2 ring-white/20">
{formData.avatar ? (
<AvatarImage src={formData.avatar} alt="Аватар сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleAvatarUpload(e.target.files[0])}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Изменить фото'}
</Button>
</div>
<div className="flex-1 grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Имя <span className="text-red-400">*</span>
</Label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40 h-10"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Фамилия <span className="text-red-400">*</span>
</Label>
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className="glass-input text-white placeholder:text-white/40 h-10"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Отчество</Label>
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Дата рождения</Label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="glass-input text-white h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Серия паспорта</Label>
<Input
value={formData.passportSeries}
onChange={(e) => handleInputChange('passportSeries', e.target.value)}
placeholder="1234"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Номер паспорта</Label>
<Input
value={formData.passportNumber}
onChange={(e) => handleInputChange('passportNumber', e.target.value)}
placeholder="567890"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Кем выдан</Label>
<Input
value={formData.passportIssued}
onChange={(e) => handleInputChange('passportIssued', e.target.value)}
placeholder="ОУФМС России по г. Москве"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Дата выдачи</Label>
<Input
type="date"
value={formData.passportDate}
onChange={(e) => handleInputChange('passportDate', e.target.value)}
className="glass-input text-white h-10"
/>
</div>
</div>
</div>
<div className="mt-4">
<Label className="text-white/80 text-sm mb-2 block">Адрес проживания</Label>
<Input
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="Москва, ул. Ленина, 10, кв. 5"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</Card>
{/* Рабочая информация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Трудовая деятельность</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Должность <span className="text-red-400">*</span>
</Label>
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40 h-10"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Дата приема на работу</Label>
<Input
type="date"
value={formData.hireDate}
onChange={(e) => handleInputChange('hireDate', e.target.value)}
className="glass-input text-white h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Статус</Label>
<Select
value={formData.status}
onValueChange={(value: 'active' | 'vacation' | 'sick' | 'inactive') => handleInputChange('status', value)}
>
<SelectTrigger className="glass-input text-white h-10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-white/20">
<SelectItem value="active" className="text-white hover:bg-white/10">Активен</SelectItem>
<SelectItem value="vacation" className="text-white hover:bg-white/10">В отпуске</SelectItem>
<SelectItem value="sick" className="text-white hover:bg-white/10">На больничном</SelectItem>
<SelectItem value="inactive" className="text-white hover:bg-white/10">Неактивен</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="mt-4">
<Label className="text-white/80 text-sm mb-2 block">Зарплата ()</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
placeholder="80000"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</Card>
{/* Контактная информация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Контактные данные</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Телефон</Label>
<Input
value={formData.phone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('phone', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Email</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Экстренный контакт</Label>
<Input
value={formData.emergencyContact}
onChange={(e) => handleInputChange('emergencyContact', e.target.value)}
placeholder="ФИО близкого родственника"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Телефон экстренного контакта</Label>
<Input
value={formData.emergencyPhone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('emergencyPhone', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</div>
</Card>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
>
Отмена
</Button>
<Button
type="submit"
disabled={loading || isUploadingAvatar}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
{loading ? 'Сохранение...' : (employee ? 'Сохранить изменения' : 'Добавить сотрудника')}
</Button>
</div>
</form>
)
}

View File

@ -0,0 +1,521 @@
"use client"
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Camera,
User,
X,
Save,
UserPlus,
Phone,
Mail,
Briefcase,
DollarSign,
Calendar,
FileText,
MessageCircle
} from 'lucide-react'
import { toast } from 'sonner'
interface EmployeeInlineFormProps {
onSave: (employeeData: {
firstName: string
lastName: string
middleName?: string
birthDate?: string
phone: string
email?: string
position: string
salary?: number
avatar?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
hireDate: string
}) => void
onCancel: () => void
isLoading?: boolean
}
export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: EmployeeInlineFormProps) {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
middleName: '',
birthDate: '',
phone: '',
telegram: '',
whatsapp: '',
email: '',
position: '',
salary: 0,
avatar: '',
passportPhoto: ''
})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
const [showPassportPreview, setShowPassportPreview] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(null)
const handleInputChange = (field: string, value: string | number) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
const handleFileUpload = async (file: File, type: 'avatar' | 'passport') => {
const setLoading = type === 'avatar' ? setIsUploadingAvatar : setIsUploadingPassport
setLoading(true)
try {
const formDataUpload = new FormData()
formDataUpload.append('file', file)
let endpoint: string
if (type === 'avatar') {
// Для аватара используем upload-avatar API
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
endpoint = '/api/upload-avatar'
} else {
// Для паспорта используем специальный API для документов сотрудников
formDataUpload.append('documentType', 'passport')
endpoint = '/api/upload-employee-document'
}
const response = await fetch(endpoint, {
method: 'POST',
body: formDataUpload
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Ошибка загрузки ${type === 'avatar' ? 'аватара' : 'паспорта'}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Неизвестная ошибка при загрузке')
}
setFormData(prev => ({
...prev,
[type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url
}))
toast.success(`${type === 'avatar' ? 'Фото' : 'Паспорт'} успешно загружен`)
} catch (error) {
console.error(`Error uploading ${type}:`, error)
const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'фото' : 'паспорта'}`
toast.error(errorMessage)
} finally {
setLoading(false)
}
}
const formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Валидация обязательных полей
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
return
}
// Подготавливаем данные для отправки
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName || undefined,
birthDate: formData.birthDate || undefined,
phone: formData.phone,
email: formData.email || undefined,
position: formData.position,
salary: formData.salary || undefined,
avatar: formData.avatar || undefined,
telegram: formData.telegram || undefined,
whatsapp: formData.whatsapp || undefined,
passportPhoto: formData.passportPhoto || undefined,
hireDate: new Date().toISOString().split('T')[0]
}
onSave(employeeData)
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
return `${first}${last}`
}
return (
<Card className="glass-card mb-6">
<CardHeader className="pb-4">
<CardTitle className="text-white text-xl flex items-center gap-3">
<UserPlus className="h-6 w-6 text-purple-400" />
Добавить нового сотрудника
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Фотографии */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Фото сотрудника */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<Camera className="h-4 w-4" />
Фото сотрудника
</Label>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20 ring-2 ring-white/20">
{formData.avatar ? (
<AvatarImage src={formData.avatar} alt="Фото сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => avatarInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Загрузить фото'}
</Button>
{formData.avatar && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, avatar: '' }))}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-2" />
Удалить
</Button>
)}
</div>
</div>
</div>
{/* Фото паспорта */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Фото паспорта
</Label>
<div className="space-y-3">
{formData.passportPhoto ? (
<div className="relative">
<img
src={formData.passportPhoto}
alt="Паспорт"
className="w-full h-auto max-h-48 object-contain rounded-lg border border-white/20 bg-white/5 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPassportPreview(true)}
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, passportPhoto: '' }))}
className="absolute top-2 right-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
Нажмите для увеличения
</div>
</div>
) : (
<div className="h-48 border-2 border-dashed border-white/20 rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="h-8 w-8 text-white/40 mx-auto mb-2" />
<p className="text-white/60 text-sm">Паспорт не загружен</p>
<p className="text-white/40 text-xs mt-1">Рекомендуемый формат: JPG, PNG</p>
</div>
</div>
)}
<input
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => passportInputRef.current?.click()}
disabled={isUploadingPassport}
className="w-full glass-secondary text-white hover:text-white"
>
<FileText className="h-4 w-4 mr-2" />
{isUploadingPassport ? 'Загрузка...' : 'Загрузить паспорт'}
</Button>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Основная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<User className="h-4 w-4" />
Личные данные
</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Имя <span className="text-red-400">*</span>
</Label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Фамилия <span className="text-red-400">*</span>
</Label>
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Отчество</Label>
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Дата рождения
</Label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="glass-input text-white"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Контактная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Phone className="h-4 w-4" />
Контактная информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Телефон <span className="text-red-400">*</span>
</Label>
<Input
value={formData.phone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('phone', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
Telegram
</Label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
WhatsApp
</Label>
<Input
value={formData.whatsapp}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('whatsapp', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<Mail className="h-3 w-3" />
Email
</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Рабочая информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Рабочая информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Должность <span className="text-red-400">*</span>
</Label>
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<DollarSign className="h-3 w-3" />
Зарплата
</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
placeholder="80000"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300"
>
<X className="h-4 w-4 mr-2" />
Отмена
</Button>
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
{isLoading ? 'Сохранение...' : (
<>
<Save className="h-4 w-4 mr-2" />
Добавить сотрудника
</>
)}
</Button>
</div>
</form>
</CardContent>
{/* Превью паспорта */}
<Dialog open={showPassportPreview} onOpenChange={setShowPassportPreview}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden glass-card">
<DialogHeader>
<DialogTitle className="text-white">Фото паспорта</DialogTitle>
</DialogHeader>
<div className="flex justify-center">
<img
src={formData.passportPhoto}
alt="Паспорт"
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@ -35,15 +35,26 @@ interface EmployeeSchedule {
days: WorkDay[] days: WorkDay[]
} }
// Моковые данные сотрудников для календаря interface Employee {
const scheduleEmployees = [ id: string
{ id: '1', name: 'Александр Петров', avatar: undefined }, firstName: string
{ id: '2', name: 'Мария Иванова', avatar: undefined }, lastName: string
{ id: '3', name: 'Дмитрий Сидоров', avatar: undefined }, position: string
{ id: '4', name: 'Анна Козлова', avatar: undefined } department?: string
] phone: string
email: string
avatar?: string
hireDate: string
status: 'active' | 'vacation' | 'sick' | 'inactive'
salary: number
address: string
}
export function EmployeeSchedule() { interface EmployeeScheduleProps {
employees: Employee[]
}
export function EmployeeSchedule({ employees }: EmployeeScheduleProps) {
const [currentDate, setCurrentDate] = useState(new Date()) const [currentDate, setCurrentDate] = useState(new Date())
const [selectedEmployee, setSelectedEmployee] = useState<string>('all') const [selectedEmployee, setSelectedEmployee] = useState<string>('all')
const [schedules, setSchedules] = useState<Record<string, EmployeeSchedule>>({}) const [schedules, setSchedules] = useState<Record<string, EmployeeSchedule>>({})
@ -295,9 +306,9 @@ export function EmployeeSchedule() {
<SelectItem value="all" className="text-white hover:bg-white/10"> <SelectItem value="all" className="text-white hover:bg-white/10">
Все сотрудники Все сотрудники
</SelectItem> </SelectItem>
{scheduleEmployees.map(emp => ( {employees.map(emp => (
<SelectItem key={emp.id} value={emp.id} className="text-white hover:bg-white/10"> <SelectItem key={emp.id} value={emp.id} className="text-white hover:bg-white/10">
{emp.name} {emp.firstName} {emp.lastName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -325,7 +336,7 @@ export function EmployeeSchedule() {
</Card> </Card>
{/* Календарь для каждого сотрудника */} {/* Календарь для каждого сотрудника */}
{(selectedEmployee === 'all' ? scheduleEmployees : scheduleEmployees.filter(e => e.id === selectedEmployee)).map(employee => { {(selectedEmployee === 'all' ? employees : employees.filter(e => e.id === selectedEmployee)).map(employee => {
const stats = getMonthStats(employee.id) const stats = getMonthStats(employee.id)
return ( return (
@ -335,14 +346,14 @@ export function EmployeeSchedule() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="h-12 w-12 ring-2 ring-white/20"> <Avatar className="h-12 w-12 ring-2 ring-white/20">
{employee.avatar ? ( {employee.avatar ? (
<AvatarImage src={employee.avatar} alt={employee.name} /> <AvatarImage src={employee.avatar} alt={`${employee.firstName} ${employee.lastName}`} />
) : null} ) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold"> <AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold">
{employee.name.split(' ').map(n => n.charAt(0)).join('')} {employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<h3 className="text-white font-semibold text-lg">{employee.name}</h3> <h3 className="text-white font-semibold text-lg">{employee.firstName} {employee.lastName}</h3>
<p className="text-white/70 text-sm">Табель работы за {monthNames[currentMonth].toLowerCase()}</p> <p className="text-white/70 text-sm">Табель работы за {monthNames[currentMonth].toLowerCase()}</p>
</div> </div>
</div> </div>

View File

@ -1,23 +1,241 @@
"use client" "use client"
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Sidebar } from '@/components/dashboard/sidebar' import { Sidebar } from '@/components/dashboard/sidebar'
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { EmployeesList } from './employees-list' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { EmployeeSchedule } from './employee-schedule' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { EmployeeForm } from './employee-form'
import { EmployeeInlineForm } from './employee-inline-form'
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
import { toast } from 'sonner'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { GET_MY_EMPLOYEES } from '@/graphql/queries'
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
import { import {
Users, Users,
Calendar, Calendar,
Search, Search,
Plus, Plus,
FileText FileText,
Edit,
UserX,
Phone,
Mail,
Download,
BarChart3,
CheckCircle,
XCircle,
Plane,
Activity,
Clock
} from 'lucide-react' } from 'lucide-react'
// Интерфейс сотрудника
interface Employee {
id: string
firstName: string
lastName: string
middleName?: string
position: string
phone: string
email?: string
avatar?: string
hireDate: string
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
salary?: number
address?: string
birthDate?: string
passportSeries?: string
passportNumber?: string
passportIssued?: string
passportDate?: string
emergencyContact?: string
emergencyPhone?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
createdAt: string
updatedAt: string
}
export function EmployeesDashboard() { export function EmployeesDashboard() {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [showEditForm, setShowEditForm] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
// GraphQL запросы и мутации
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES)
const [createEmployee] = useMutation(CREATE_EMPLOYEE)
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE)
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE)
const employees = data?.myEmployees || []
const handleEditEmployee = (employee: Employee) => {
setEditingEmployee(employee)
setShowEditForm(true)
setShowAddForm(false) // Закрываем форму добавления если открыта
}
const handleEmployeeSaved = async (employeeData: Partial<Employee>) => {
try {
if (editingEmployee) {
// Обновление существующего сотрудника
const { data } = await updateEmployee({
variables: {
id: editingEmployee.id,
input: employeeData
}
})
if (data?.updateEmployee?.success) {
toast.success('Сотрудник успешно обновлен')
refetch()
}
} else {
// Добавление нового сотрудника
const { data } = await createEmployee({
variables: { input: employeeData }
})
if (data?.createEmployee?.success) {
toast.success('Сотрудник успешно добавлен')
refetch()
}
}
setShowEditForm(false)
setEditingEmployee(null)
} catch (error) {
console.error('Error saving employee:', error)
toast.error('Ошибка при сохранении сотрудника')
}
}
const handleCreateEmployee = async (employeeData: Partial<Employee>) => {
setCreateLoading(true)
try {
const { data } = await createEmployee({
variables: { input: employeeData }
})
if (data?.createEmployee?.success) {
toast.success('Сотрудник успешно добавлен!')
setShowAddForm(false)
refetch()
}
} catch (error) {
console.error('Error creating employee:', error)
toast.error('Ошибка при создании сотрудника')
} finally {
setCreateLoading(false)
}
}
const handleEmployeeDeleted = async (employeeId: string) => {
try {
setDeletingEmployeeId(employeeId)
const { data } = await deleteEmployee({
variables: { id: employeeId }
})
if (data?.deleteEmployee) {
toast.success('Сотрудник успешно уволен')
refetch()
}
} catch (error) {
console.error('Error deleting employee:', error)
toast.error('Ошибка при увольнении сотрудника')
} finally {
setDeletingEmployeeId(null)
}
}
const exportToCSV = () => {
const csvContent = [
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
...employees.map((emp: Employee) => [
`${emp.firstName} ${emp.lastName}`,
emp.position,
emp.status === 'ACTIVE' ? 'Активен' :
emp.status === 'VACATION' ? 'В отпуске' :
emp.status === 'SICK' ? 'На больничном' : 'Уволен',
emp.salary?.toString() || '',
emp.phone,
emp.email || '',
new Date(emp.hireDate).toLocaleDateString('ru-RU')
])
].map(row => row.join(',')).join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `employees_report_${new Date().toISOString().split('T')[0]}.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success('Отчет успешно экспортирован')
}
const generateReport = () => {
const stats = {
total: employees.length,
active: employees.filter((e: Employee) => e.status === 'ACTIVE').length,
vacation: employees.filter((e: Employee) => e.status === 'VACATION').length,
sick: employees.filter((e: Employee) => e.status === 'SICK').length,
inactive: employees.filter((e: Employee) => e.status === 'FIRED').length,
avgSalary: Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length)
}
const reportText = `
ОТЧЕТ ПО СОТРУДНИКАМ
Дата: ${new Date().toLocaleDateString('ru-RU')}
ОБЩАЯ СТАТИСТИКА:
Всего сотрудников: ${stats.total}
• Активных: ${stats.active}
В отпуске: ${stats.vacation}
На больничном: ${stats.sick}
• Неактивных: ${stats.inactive}
• Средняя зарплата: ${stats.avgSalary.toLocaleString('ru-RU')}
СПИСОК СОТРУДНИКОВ:
${employees.map((emp: Employee) =>
`${emp.firstName} ${emp.lastName} - ${emp.position}`
).join('\n')}
`.trim()
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `employees_summary_${new Date().toISOString().split('T')[0]}.txt`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success('Сводный отчет создан')
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-56 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center h-64">
<div className="text-white text-xl">Загрузка сотрудников...</div>
</div>
</div>
</main>
</div>
)
}
return ( return (
<div className="min-h-screen bg-gradient-smooth flex"> <div className="min-h-screen bg-gradient-smooth flex">
@ -33,10 +251,15 @@ export function EmployeesDashboard() {
<p className="text-white/70">Личные данные, табель работы и учет</p> <p className="text-white/70">Личные данные, табель работы и учет</p>
</div> </div>
</div> </div>
<Button className="glass-button"> <Button
onClick={() => setShowAddForm(!showAddForm)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Добавить сотрудника {showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
</Button> </Button>
</div> </div>
{/* Поиск */} {/* Поиск */}
@ -52,22 +275,37 @@ export function EmployeesDashboard() {
</div> </div>
</Card> </Card>
{/* Форма добавления сотрудника */}
{showAddForm && (
<EmployeeInlineForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
)}
{/* Форма редактирования сотрудника */}
{showEditForm && editingEmployee && (
<EmployeeEditInlineForm
employee={editingEmployee}
onSave={handleEmployeeSaved}
onCancel={() => {
setShowEditForm(false)
setEditingEmployee(null)
}}
isLoading={createLoading}
/>
)}
{/* Основной контент с вкладками */} {/* Основной контент с вкладками */}
<Tabs defaultValue="list" className="w-full"> <Tabs defaultValue="combined" className="w-full">
<TabsList className="glass-card mb-6 grid w-full grid-cols-3"> <TabsList className="glass-card mb-6 grid w-full grid-cols-2">
<TabsTrigger <TabsTrigger
value="list" value="combined"
className="text-white data-[state=active]:bg-white/20 cursor-pointer" className="text-white data-[state=active]:bg-white/20 cursor-pointer"
> >
<Users className="h-4 w-4 mr-2" /> <Users className="h-4 w-4 mr-2" />
Список сотрудников Сотрудники и табель
</TabsTrigger>
<TabsTrigger
value="schedule"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Calendar className="h-4 w-4 mr-2" />
Табель работы
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="reports" value="reports"
@ -78,25 +316,548 @@ export function EmployeesDashboard() {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="list"> <TabsContent value="combined">
<EmployeesList searchQuery={searchQuery} /> <Card className="glass-card p-6">
</TabsContent> {(() => {
const filteredEmployees = employees.filter(employee =>
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.position.toLowerCase().includes(searchQuery.toLowerCase()) ||
(employee.department && employee.department.toLowerCase().includes(searchQuery.toLowerCase()))
)
<TabsContent value="schedule"> if (filteredEmployees.length === 0) {
<EmployeeSchedule /> return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="h-8 w-8 text-white/40" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
{searchQuery ? 'Сотрудники не найдены' : 'У вас пока нет сотрудников'}
</h3>
<p className="text-white/60 text-sm mb-4">
{searchQuery
? 'Попробуйте изменить критерии поиска'
: 'Добавьте первого сотрудника в вашу команду'
}
</p>
{!searchQuery && (
<Button
onClick={() => setShowAddForm(true)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
Добавить сотрудника
</Button>
)}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Навигация по месяцам */}
<div className="flex items-center justify-between mb-6">
<h3 className="text-white font-medium text-lg">Январь 2024</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
Декабрь
</Button>
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
Сегодня
</Button>
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
Февраль
</Button>
</div>
</div>
{/* Легенда точно как в гите */}
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-green-500/20 text-green-300 border-green-500/30">
<CheckCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Рабочий день</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-gray-500/20 text-gray-300 border-gray-500/30">
<Clock className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Выходной</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-blue-500/20 text-blue-300 border-blue-500/30">
<Plane className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Отпуск</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
<Activity className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Больничный</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-red-500/20 text-red-300 border-red-500/30">
<XCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Прогул</span>
</div>
</div>
{/* Объединенный список сотрудников с табелем */}
{filteredEmployees.map((employee) => {
// Генерируем календарные дни для текущего месяца
const currentDate = new Date()
const currentMonth = currentDate.getMonth()
const currentYear = currentDate.getFullYear()
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
// Создаем массив дней с моковыми данными табеля
const generateDayStatus = (day: number) => {
const date = new Date(currentYear, currentMonth, day)
const dayOfWeek = date.getDay()
// Выходные
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
// Некоторые случайные отпуска/больничные для демонстрации
if ([15, 16].includes(day)) return 'vacation'
if ([10].includes(day)) return 'sick'
if ([22].includes(day)) return 'absent'
return 'work'
}
const getDayClass = (status: string) => {
switch (status) {
case 'work': return 'bg-green-500/30 border-green-500/50'
case 'weekend': return 'bg-gray-500/30 border-gray-500/50'
case 'vacation': return 'bg-blue-500/30 border-blue-500/50'
case 'sick': return 'bg-yellow-500/30 border-yellow-500/50'
case 'absent': return 'bg-red-500/30 border-red-500/50'
default: return 'bg-white/10 border-white/20'
}
}
// Подсчитываем статистику
const stats = {
workDays: 0,
vacationDays: 0,
sickDays: 0,
absentDays: 0,
totalHours: 0
}
for (let day = 1; day <= daysInMonth; day++) {
const status = generateDayStatus(day)
switch (status) {
case 'work':
stats.workDays++
stats.totalHours += 8
break
case 'vacation':
stats.vacationDays++
break
case 'sick':
stats.sickDays++
break
case 'absent':
stats.absentDays++
break
}
}
return (
<Card key={employee.id} className="glass-card p-6">
<div className="flex flex-col lg:flex-row gap-6">
{/* Информация о сотруднике */}
<div className="lg:w-80 flex-shrink-0">
<div className="flex items-start space-x-4 mb-4">
<Avatar className="h-16 w-16 ring-2 ring-white/20">
{employee.avatar ? (
<AvatarImage
src={employee.avatar}
alt={`${employee.firstName} ${employee.lastName}`}
onError={(e) => {
console.error('Ошибка загрузки аватара:', employee.avatar);
e.currentTarget.style.display = 'none';
}}
onLoad={() => console.log('Аватар загружен успешно:', employee.avatar)}
/>
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold text-lg">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-semibold text-lg truncate">
{employee.firstName} {employee.lastName}
</h3>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => handleEditEmployee(employee)}
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<UserX className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="glass-card border-white/10">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
<AlertDialogDescription className="text-white/70">
Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}?
Это действие нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleEmployeeDeleted(employee.id)}
disabled={deletingEmployeeId === employee.id}
className="bg-red-600 hover:bg-red-700 text-white"
>
{deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<p className="text-purple-300 font-medium mb-1">{employee.position}</p>
{employee.department && (
<p className="text-white/60 text-sm mb-3">{employee.department}</p>
)}
<div className="space-y-2">
<div className="flex items-center text-white/70 text-sm">
<Phone className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.phone}</span>
</div>
{employee.email && (
<div className="flex items-center text-white/70 text-sm">
<Mail className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.email}</span>
</div>
)}
</div>
</div>
</div>
{/* Статистика за месяц */}
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-green-400 font-semibold text-lg">{stats.workDays}</p>
<p className="text-white/60 text-xs">Рабочих дней</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-blue-400 font-semibold text-lg">{stats.vacationDays}</p>
<p className="text-white/60 text-xs">Отпуск</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-yellow-400 font-semibold text-lg">{stats.sickDays}</p>
<p className="text-white/60 text-xs">Больничный</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-white font-semibold text-lg">{stats.totalHours}ч</p>
<p className="text-white/60 text-xs">Всего часов</p>
</div>
</div>
</div>
{/* Табель работы 1 в 1 как в гите */}
<div className="flex-1">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })}
</h4>
{/* Сетка календаря - точно как в гите */}
<div className="grid grid-cols-7 gap-2">
{/* Заголовки дней недели */}
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
{day}
</div>
))}
{/* Дни месяца */}
{(() => {
// Точная логика из гита
const calendarDays: (number | null)[] = []
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay()
const startOffset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1
// Добавляем пустые ячейки для выравнивания первой недели
for (let i = 0; i < startOffset; i++) {
calendarDays.push(null)
}
// Добавляем дни месяца
for (let day = 1; day <= daysInMonth; day++) {
calendarDays.push(day)
}
// Функции статуса и стилей точно как в гите
const getDayStatus = (day: number) => {
const date = new Date(currentYear, currentMonth, day)
const dayOfWeek = date.getDay()
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
if ([15, 16].includes(day)) return 'vacation'
if ([10].includes(day)) return 'sick'
if ([22].includes(day)) return 'absent'
return 'work'
}
const getCellStyle = (status: string) => {
switch (status) {
case 'work':
return 'bg-green-500/20 text-green-300 border-green-500/30'
case 'weekend':
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
case 'vacation':
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
case 'sick':
return 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30'
case 'absent':
return 'bg-red-500/20 text-red-300 border-red-500/30'
default:
return 'bg-white/10 text-white/70 border-white/20'
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'work':
return <CheckCircle className="h-3 w-3" />
case 'weekend':
return <Clock className="h-3 w-3" />
case 'vacation':
return <Plane className="h-3 w-3" />
case 'sick':
return <Activity className="h-3 w-3" />
case 'absent':
return <XCircle className="h-3 w-3" />
default:
return null
}
}
return calendarDays.map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="p-2"></div>
}
const status = getDayStatus(day)
const hours = status === 'work' ? 8 : status === 'vacation' || status === 'sick' ? 8 : 0
const isToday = new Date().getDate() === day &&
new Date().getMonth() === currentMonth &&
new Date().getFullYear() === currentYear
return (
<div
key={`${employee.id}-${day}`}
className={`
relative p-2 min-h-[60px] border rounded-lg cursor-pointer
transition-all duration-200 hover:scale-105
${getCellStyle(status)}
${isToday ? 'ring-2 ring-white/50' : ''}
`}
onClick={() => {
// Циклично переключаем статусы - точно как в гите
// const statuses = ['work', 'weekend', 'vacation', 'sick', 'absent']
// const currentIndex = statuses.indexOf(status)
// const nextStatus = statuses[(currentIndex + 1) % statuses.length]
// changeDayStatus(employee.id, day, nextStatus)
}}
>
<div className="flex flex-col items-center justify-center h-full">
<div className="flex items-center gap-1 mb-1">
{getStatusIcon(status)}
<span className="font-semibold text-sm">{day}</span>
</div>
{hours > 0 && (
<span className="text-xs opacity-80">{hours}ч</span>
)}
</div>
{isToday && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
)}
</div>
)
})
})()}
</div>
</div>
</div>
</Card>
)
})}
</div>
)
})()}
</Card>
</TabsContent> </TabsContent>
<TabsContent value="reports"> <TabsContent value="reports">
<Card className="glass-card p-8"> {employees.length === 0 ? (
<Card className="glass-card p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center"> <div className="text-center">
<FileText className="h-16 w-16 text-white/40 mx-auto mb-4" /> <div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<h3 className="text-xl font-semibold text-white mb-2">Отчеты</h3> <BarChart3 className="h-8 w-8 text-white/40" />
<p className="text-white/70 mb-4"> </div>
Генерация отчетов по работе сотрудников <h3 className="text-lg font-medium text-white mb-2">Нет данных для отчетов</h3>
<p className="text-white/60 text-sm mb-4">
Добавьте сотрудников, чтобы генерировать отчеты и аналитику
</p> </p>
<p className="text-white/50 text-sm">Функция в разработке</p> <Button
onClick={() => setShowAddForm(true)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
Добавить сотрудника
</Button>
</div>
</div> </div>
</Card> </Card>
) : (
<div className="space-y-6">
{/* Статистические карточки */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Всего сотрудников</p>
<p className="text-2xl font-bold text-white">{employees.length}</p>
</div>
<Users className="h-8 w-8 text-purple-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Активных</p>
<p className="text-2xl font-bold text-green-400">
{employees.filter((e: Employee) => e.status === 'ACTIVE').length}
</p>
</div>
<BarChart3 className="h-8 w-8 text-green-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Средняя зарплата</p>
<p className="text-2xl font-bold text-blue-400">
{employees.length > 0
? Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length).toLocaleString('ru-RU')
: '0'}
</p>
</div>
<FileText className="h-8 w-8 text-blue-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Отделов</p>
<p className="text-2xl font-bold text-orange-400">
{new Set(employees.map((e: Employee) => e.department).filter(Boolean)).size}
</p>
</div>
<Calendar className="h-8 w-8 text-orange-400" />
</div>
</Card>
</div>
{/* Экспорт отчетов */}
<Card className="glass-card p-6">
<h3 className="text-white font-medium mb-4 flex items-center gap-2">
<Download className="h-5 w-5" />
Экспорт отчетов
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<h4 className="text-white/80 font-medium">Детальный отчет (CSV)</h4>
<p className="text-white/60 text-sm">
Полная информация о всех сотрудниках в формате таблицы для Excel/Google Sheets
</p>
<Button
onClick={exportToCSV}
className="w-full glass-button"
>
<Download className="h-4 w-4 mr-2" />
Скачать CSV
</Button>
</div>
<div className="space-y-3">
<h4 className="text-white/80 font-medium">Сводный отчет (TXT)</h4>
<p className="text-white/60 text-sm">
Краткая статистика и список сотрудников в текстовом формате
</p>
<Button
onClick={generateReport}
className="w-full glass-button"
>
<BarChart3 className="h-4 w-4 mr-2" />
Создать отчет
</Button>
</div>
</div>
</Card>
{/* Аналитика по отделам */}
<Card className="glass-card p-6">
<h3 className="text-white font-medium mb-4">Распределение по отделам</h3>
<div className="space-y-3">
{Array.from(new Set(employees.map(e => e.department).filter(Boolean))).map(dept => {
const deptEmployees = employees.filter(e => e.department === dept)
const percentage = Math.round((deptEmployees.length / employees.length) * 100)
return (
<div key={dept} className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-white/80 text-sm">{dept}</span>
<span className="text-white/60 text-xs">{deptEmployees.length} чел. ({percentage}%)</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div
className="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full"
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
</div>
)
})}
</div>
</Card>
</div>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@ -18,7 +19,9 @@ import {
User, User,
Briefcase, Briefcase,
Save, Save,
X X,
Trash2,
UserX
} from 'lucide-react' } from 'lucide-react'
// Интерфейс сотрудника // Интерфейс сотрудника
@ -27,7 +30,7 @@ interface Employee {
firstName: string firstName: string
lastName: string lastName: string
position: string position: string
department: string department?: string
phone: string phone: string
email: string email: string
avatar?: string avatar?: string
@ -95,10 +98,12 @@ const mockEmployees: Employee[] = [
interface EmployeesListProps { interface EmployeesListProps {
searchQuery: string searchQuery: string
employees: Employee[]
onEditEmployee: (employee: Employee) => void
onDeleteEmployee: (employeeId: string) => void
} }
export function EmployeesList({ searchQuery }: EmployeesListProps) { export function EmployeesList({ searchQuery, employees, onEditEmployee, onDeleteEmployee }: EmployeesListProps) {
const [employees, setEmployees] = useState<Employee[]>(mockEmployees)
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null) const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null)
const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isEditModalOpen, setIsEditModalOpen] = useState(false)
@ -106,7 +111,7 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
const filteredEmployees = employees.filter(employee => const filteredEmployees = employees.filter(employee =>
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) || `${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.position.toLowerCase().includes(searchQuery.toLowerCase()) || employee.position.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.department.toLowerCase().includes(searchQuery.toLowerCase()) (employee.department && employee.department.toLowerCase().includes(searchQuery.toLowerCase()))
) )
const getStatusBadge = (status: Employee['status']) => { const getStatusBadge = (status: Employee['status']) => {
@ -133,18 +138,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
} }
const handleEditEmployee = (employee: Employee) => { const handleEditEmployee = (employee: Employee) => {
setSelectedEmployee(employee) onEditEmployee(employee)
setIsEditModalOpen(true)
} }
const handleSaveEmployee = () => { const handleDeleteEmployee = (employee: Employee) => {
if (selectedEmployee) { onDeleteEmployee(employee.id)
setEmployees(prev =>
prev.map(emp => emp.id === selectedEmployee.id ? selectedEmployee : emp)
)
setIsEditModalOpen(false)
setSelectedEmployee(null)
}
} }
return ( return (
@ -214,6 +212,7 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
<h3 className="text-white font-semibold text-lg truncate"> <h3 className="text-white font-semibold text-lg truncate">
{employee.firstName} {employee.lastName} {employee.firstName} {employee.lastName}
</h3> </h3>
<div className="flex gap-1">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -222,6 +221,39 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<UserX className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="glass-card border-white/10">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
<AlertDialogDescription className="text-white/70">
Вы уверены, что хотите уволить {employee.firstName} {employee.lastName}?
Это действие изменит статус сотрудника на &quot;Неактивен&quot; и удалит его из активного списка.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteEmployee(employee)}
className="bg-red-600 hover:bg-red-700 text-white"
>
Уволить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div> </div>
<p className="text-purple-300 font-medium mb-1">{employee.position}</p> <p className="text-purple-300 font-medium mb-1">{employee.position}</p>
@ -389,11 +421,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
<div className="flex gap-2 pt-4"> <div className="flex gap-2 pt-4">
<Button <Button
onClick={handleSaveEmployee} onClick={() => setIsEditModalOpen(false)}
className="glass-button flex-1" className="glass-button flex-1"
> >
<Save className="h-4 w-4 mr-2" /> <Save className="h-4 w-4 mr-2" />
Сохранить Закрыть
</Button> </Button>
<Button <Button
variant="outline" variant="outline"

View File

@ -875,3 +875,77 @@ export const REMOVE_FROM_FAVORITES = gql`
} }
} }
` `
// Мутации для сотрудников
export const CREATE_EMPLOYEE = gql`
mutation CreateEmployee($input: CreateEmployeeInput!) {
createEmployee(input: $input) {
success
message
employee {
id
firstName
lastName
middleName
birthDate
avatar
position
department
hireDate
salary
status
phone
email
emergencyContact
emergencyPhone
createdAt
updatedAt
}
}
}
`
export const UPDATE_EMPLOYEE = gql`
mutation UpdateEmployee($id: ID!, $input: UpdateEmployeeInput!) {
updateEmployee(id: $id, input: $input) {
success
message
employee {
id
firstName
lastName
middleName
birthDate
avatar
passportSeries
passportNumber
passportIssued
passportDate
address
position
department
hireDate
salary
status
phone
email
emergencyContact
emergencyPhone
createdAt
updatedAt
}
}
}
`
export const DELETE_EMPLOYEE = gql`
mutation DeleteEmployee($id: ID!) {
deleteEmployee(id: $id)
}
`
export const UPDATE_EMPLOYEE_SCHEDULE = gql`
mutation UpdateEmployeeSchedule($input: UpdateScheduleInput!) {
updateEmployeeSchedule(input: $input)
}
`

View File

@ -477,3 +477,65 @@ export const GET_MY_FAVORITES = gql`
} }
} }
` `
// Запросы для сотрудников
export const GET_MY_EMPLOYEES = gql`
query GetMyEmployees {
myEmployees {
id
firstName
lastName
middleName
birthDate
avatar
passportSeries
passportNumber
passportIssued
passportDate
address
position
department
hireDate
salary
status
phone
email
telegram
whatsapp
passportPhoto
emergencyContact
emergencyPhone
createdAt
updatedAt
}
}
`
export const GET_EMPLOYEE = gql`
query GetEmployee($id: ID!) {
employee(id: $id) {
id
firstName
lastName
middleName
birthDate
avatar
passportSeries
passportNumber
passportIssued
passportDate
address
position
department
hireDate
salary
status
phone
email
emergencyContact
emergencyPhone
createdAt
updatedAt
}
}
`

View File

@ -676,6 +676,72 @@ export const resolvers = {
}) })
return favorites.map(favorite => favorite.product) return favorites.map(favorite => favorite.product)
},
// Сотрудники организации
myEmployees: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
const employees = await prisma.employee.findMany({
where: { organizationId: currentUser.organization.id },
include: {
organization: true
},
orderBy: { createdAt: 'desc' }
})
return employees
},
// Получение сотрудника по ID
employee: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
const employee = await prisma.employee.findFirst({
where: {
id: args.id,
organizationId: currentUser.organization.id
},
include: {
organization: true
}
})
return employee
} }
}, },
@ -3041,6 +3107,206 @@ export const resolvers = {
message: 'Ошибка при удалении из избранного' message: 'Ошибка при удалении из избранного'
} }
} }
},
// Создать сотрудника
createEmployee: async (_: unknown, args: { input: any }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
try {
const employee = await prisma.employee.create({
data: {
...args.input,
organizationId: currentUser.organization.id,
birthDate: args.input.birthDate ? new Date(args.input.birthDate) : undefined,
passportDate: args.input.passportDate ? new Date(args.input.passportDate) : undefined,
hireDate: new Date(args.input.hireDate)
},
include: {
organization: true
}
})
return {
success: true,
message: 'Сотрудник успешно добавлен',
employee
}
} catch (error) {
console.error('Error creating employee:', error)
return {
success: false,
message: 'Ошибка при создании сотрудника'
}
}
},
// Обновить сотрудника
updateEmployee: async (_: unknown, args: { id: string; input: any }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
try {
const employee = await prisma.employee.update({
where: {
id: args.id,
organizationId: currentUser.organization.id
},
data: {
...args.input,
birthDate: args.input.birthDate ? new Date(args.input.birthDate) : undefined,
passportDate: args.input.passportDate ? new Date(args.input.passportDate) : undefined,
hireDate: args.input.hireDate ? new Date(args.input.hireDate) : undefined
},
include: {
organization: true
}
})
return {
success: true,
message: 'Сотрудник успешно обновлен',
employee
}
} catch (error) {
console.error('Error updating employee:', error)
return {
success: false,
message: 'Ошибка при обновлении сотрудника'
}
}
},
// Удалить сотрудника
deleteEmployee: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
try {
await prisma.employee.delete({
where: {
id: args.id,
organizationId: currentUser.organization.id
}
})
return true
} catch (error) {
console.error('Error deleting employee:', error)
return false
}
},
// Обновить табель сотрудника
updateEmployeeSchedule: async (_: unknown, args: { input: any }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
try {
// Проверяем что сотрудник принадлежит организации
const employee = await prisma.employee.findFirst({
where: {
id: args.input.employeeId,
organizationId: currentUser.organization.id
}
})
if (!employee) {
throw new GraphQLError('Сотрудник не найден')
}
// Создаем или обновляем запись табеля
await prisma.employeeSchedule.upsert({
where: {
employeeId_date: {
employeeId: args.input.employeeId,
date: new Date(args.input.date)
}
},
create: {
employeeId: args.input.employeeId,
date: new Date(args.input.date),
status: args.input.status,
hoursWorked: args.input.hoursWorked,
notes: args.input.notes
},
update: {
status: args.input.status,
hoursWorked: args.input.hoursWorked,
notes: args.input.notes
}
})
return true
} catch (error) {
console.error('Error updating employee schedule:', error)
return false
}
} }
}, },
@ -3120,5 +3386,61 @@ export const resolvers = {
} }
return parent.updatedAt return parent.updatedAt
} }
},
Employee: {
birthDate: (parent: { birthDate?: Date | string | null }) => {
if (!parent.birthDate) return null
if (parent.birthDate instanceof Date) {
return parent.birthDate.toISOString()
}
return parent.birthDate
},
passportDate: (parent: { passportDate?: Date | string | null }) => {
if (!parent.passportDate) return null
if (parent.passportDate instanceof Date) {
return parent.passportDate.toISOString()
}
return parent.passportDate
},
hireDate: (parent: { hireDate: Date | string }) => {
if (parent.hireDate instanceof Date) {
return parent.hireDate.toISOString()
}
return parent.hireDate
},
createdAt: (parent: { createdAt: Date | string }) => {
if (parent.createdAt instanceof Date) {
return parent.createdAt.toISOString()
}
return parent.createdAt
},
updatedAt: (parent: { updatedAt: Date | string }) => {
if (parent.updatedAt instanceof Date) {
return parent.updatedAt.toISOString()
}
return parent.updatedAt
}
},
EmployeeSchedule: {
date: (parent: { date: Date | string }) => {
if (parent.date instanceof Date) {
return parent.date.toISOString()
}
return parent.date
},
createdAt: (parent: { createdAt: Date | string }) => {
if (parent.createdAt instanceof Date) {
return parent.createdAt.toISOString()
}
return parent.createdAt
},
updatedAt: (parent: { updatedAt: Date | string }) => {
if (parent.updatedAt instanceof Date) {
return parent.updatedAt.toISOString()
}
return parent.updatedAt
}
} }
} }

View File

@ -43,6 +43,10 @@ export const typeDefs = gql`
# Избранные товары пользователя # Избранные товары пользователя
myFavorites: [Product!]! myFavorites: [Product!]!
# Сотрудники организации
myEmployees: [Employee!]!
employee(id: ID!): Employee
} }
type Mutation { type Mutation {
@ -107,6 +111,12 @@ export const typeDefs = gql`
# Работа с избранным # Работа с избранным
addToFavorites(productId: ID!): FavoritesResponse! addToFavorites(productId: ID!): FavoritesResponse!
removeFromFavorites(productId: ID!): FavoritesResponse! removeFromFavorites(productId: ID!): FavoritesResponse!
# Работа с сотрудниками
createEmployee(input: CreateEmployeeInput!): EmployeeResponse!
updateEmployee(id: ID!, input: UpdateEmployeeInput!): EmployeeResponse!
deleteEmployee(id: ID!): Boolean!
updateEmployeeSchedule(input: UpdateScheduleInput!): Boolean!
} }
# Типы данных # Типы данных
@ -470,6 +480,132 @@ export const typeDefs = gql`
favorites: [Product!] favorites: [Product!]
} }
# Типы для сотрудников
type Employee {
id: ID!
firstName: String!
lastName: String!
middleName: String
birthDate: String
avatar: String
passportPhoto: String
passportSeries: String
passportNumber: String
passportIssued: String
passportDate: String
address: String
position: String!
department: String
hireDate: String!
salary: Float
status: EmployeeStatus!
phone: String!
email: String
telegram: String
whatsapp: String
emergencyContact: String
emergencyPhone: String
scheduleRecords: [EmployeeSchedule!]!
organization: Organization!
createdAt: String!
updatedAt: String!
}
enum EmployeeStatus {
ACTIVE
VACATION
SICK
FIRED
}
type EmployeeSchedule {
id: ID!
date: String!
status: ScheduleStatus!
hoursWorked: Float
notes: String
employee: Employee!
createdAt: String!
updatedAt: String!
}
enum ScheduleStatus {
WORK
WEEKEND
VACATION
SICK
ABSENT
}
input CreateEmployeeInput {
firstName: String!
lastName: String!
middleName: String
birthDate: String
avatar: String
passportPhoto: String
passportSeries: String
passportNumber: String
passportIssued: String
passportDate: String
address: String
position: String!
department: String
hireDate: String!
salary: Float
phone: String!
email: String
telegram: String
whatsapp: String
emergencyContact: String
emergencyPhone: String
}
input UpdateEmployeeInput {
firstName: String
lastName: String
middleName: String
birthDate: String
avatar: String
passportPhoto: String
passportSeries: String
passportNumber: String
passportIssued: String
passportDate: String
address: String
position: String
department: String
hireDate: String
salary: Float
status: EmployeeStatus
phone: String
email: String
telegram: String
whatsapp: String
emergencyContact: String
emergencyPhone: String
}
input UpdateScheduleInput {
employeeId: ID!
date: String!
status: ScheduleStatus!
hoursWorked: Float
notes: String
}
type EmployeeResponse {
success: Boolean!
message: String!
employee: Employee
}
type EmployeesResponse {
success: Boolean!
message: String!
employees: [Employee!]!
}
# JSON скаляр # JSON скаляр
scalar JSON scalar JSON
` `