Добавлены модели и функциональность для управления сотрудниками, включая создание, обновление и удаление сотрудников через GraphQL. Обновлены компоненты для отображения списка сотрудников и их расписания, улучшен интерфейс взаимодействия с пользователем. Реализованы функции экспорта отчетов в CSV и TXT форматах, добавлены новые интерфейсы и типы данных для сотрудников.
This commit is contained in:
@ -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 {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
@ -10,29 +7,21 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// Модель пользователя
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
phone String @unique
|
||||
avatar String? // URL аватара в S3
|
||||
managerName String? // Имя управляющего
|
||||
avatar String?
|
||||
managerName String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Связь с организацией
|
||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
organizationId String?
|
||||
|
||||
// SMS коды для авторизации
|
||||
smsCodes SmsCode[]
|
||||
|
||||
// Отправленные сообщения
|
||||
sentMessages Message[] @relation("SentMessages")
|
||||
smsCodes SmsCode[]
|
||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// Модель для SMS кодов
|
||||
model SmsCode {
|
||||
id String @id @default(cuid())
|
||||
code String
|
||||
@ -42,95 +31,62 @@ model SmsCode {
|
||||
attempts Int @default(0)
|
||||
maxAttempts Int @default(3)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Связь с пользователем
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("sms_codes")
|
||||
}
|
||||
|
||||
// Модель организации
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
inn String @unique
|
||||
kpp String? // КПП
|
||||
name String? // Краткое наименование
|
||||
fullName String? // Полное наименование
|
||||
ogrn String? // ОГРН организации
|
||||
ogrnDate DateTime? // Дата выдачи ОГРН
|
||||
kpp String?
|
||||
name String?
|
||||
fullName String?
|
||||
ogrn String?
|
||||
ogrnDate DateTime?
|
||||
type OrganizationType
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Адрес организации
|
||||
address String? // Адрес одной строкой
|
||||
addressFull String? // Полный адрес с индексом
|
||||
|
||||
// Статус организации
|
||||
status String? // ACTIVE, LIQUIDATED и т.д.
|
||||
actualityDate DateTime? // Дата последних изменений
|
||||
registrationDate DateTime? // Дата регистрации
|
||||
liquidationDate DateTime? // Дата ликвидации
|
||||
|
||||
// Руководитель
|
||||
managementName String? // ФИО или наименование руководителя
|
||||
managementPost String? // Должность руководителя
|
||||
|
||||
// ОПФ (Организационно-правовая форма)
|
||||
opfCode String? // Код ОКОПФ
|
||||
opfFull String? // Полное название ОПФ
|
||||
opfShort String? // Краткое название ОПФ
|
||||
|
||||
// Коды статистики
|
||||
okato String? // Код ОКАТО
|
||||
oktmo String? // Код ОКТМО
|
||||
okpo String? // Код ОКПО
|
||||
okved String? // Основной код ОКВЭД
|
||||
|
||||
// Контакты
|
||||
phones Json? // Массив телефонов
|
||||
emails Json? // Массив email адресов
|
||||
|
||||
// Финансовые данные
|
||||
employeeCount Int? // Численность сотрудников
|
||||
revenue BigInt? // Выручка
|
||||
taxSystem String? // Система налогообложения
|
||||
|
||||
// Полные данные из DaData (для полноты)
|
||||
address String?
|
||||
addressFull String?
|
||||
status String?
|
||||
actualityDate DateTime?
|
||||
registrationDate DateTime?
|
||||
liquidationDate DateTime?
|
||||
managementName String?
|
||||
managementPost String?
|
||||
opfCode String?
|
||||
opfFull String?
|
||||
opfShort String?
|
||||
okato String?
|
||||
oktmo String?
|
||||
okpo String?
|
||||
okved String?
|
||||
phones Json?
|
||||
emails Json?
|
||||
employeeCount Int?
|
||||
revenue BigInt?
|
||||
taxSystem String?
|
||||
dadataData Json?
|
||||
|
||||
// Связи
|
||||
users User[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
// Связи контрагентов
|
||||
sentRequests CounterpartyRequest[] @relation("SentRequests")
|
||||
receivedRequests CounterpartyRequest[] @relation("ReceivedRequests")
|
||||
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
|
||||
carts Cart?
|
||||
counterpartyOf Counterparty[] @relation("CounterpartyOf")
|
||||
|
||||
// Сообщения
|
||||
sentMessages Message[] @relation("SentMessages")
|
||||
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
|
||||
receivedRequests CounterpartyRequest[] @relation("ReceivedRequests")
|
||||
sentRequests CounterpartyRequest[] @relation("SentRequests")
|
||||
employees Employee[]
|
||||
favorites Favorites[]
|
||||
receivedMessages Message[] @relation("ReceivedMessages")
|
||||
|
||||
// Услуги и расходники (только для фулфилмент центров)
|
||||
sentMessages Message[] @relation("SentMessages")
|
||||
products Product[]
|
||||
services Service[]
|
||||
supplies Supply[]
|
||||
|
||||
// Товары (только для оптовиков)
|
||||
products Product[]
|
||||
|
||||
// Корзины
|
||||
carts Cart[]
|
||||
|
||||
// Избранные товары
|
||||
favorites Favorites[]
|
||||
users User[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
// Модель для API ключей маркетплейсов
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
marketplace MarketplaceType
|
||||
@ -138,110 +94,239 @@ model ApiKey {
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Данные для валидации (например, информация о продавце)
|
||||
validationData Json?
|
||||
|
||||
// Связь с организацией
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
|
||||
@@unique([organizationId, marketplace])
|
||||
@@map("api_keys")
|
||||
}
|
||||
|
||||
// Тип организации
|
||||
enum OrganizationType {
|
||||
FULFILLMENT // Фулфилмент
|
||||
SELLER // Селлер
|
||||
LOGIST // Логистика
|
||||
WHOLESALE // Оптовик
|
||||
}
|
||||
|
||||
// Тип маркетплейса
|
||||
enum MarketplaceType {
|
||||
WILDBERRIES
|
||||
OZON
|
||||
}
|
||||
|
||||
// Модель для заявок на добавление в контрагенты
|
||||
model CounterpartyRequest {
|
||||
id String @id @default(cuid())
|
||||
status CounterpartyRequestStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Кто отправил заявку
|
||||
sender Organization @relation("SentRequests", fields: [senderId], references: [id])
|
||||
senderId String
|
||||
|
||||
// Кому отправили заявку
|
||||
receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id])
|
||||
receiverId String
|
||||
|
||||
// Комментарий к заявке
|
||||
message String?
|
||||
receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id])
|
||||
sender Organization @relation("SentRequests", fields: [senderId], references: [id])
|
||||
|
||||
@@unique([senderId, receiverId])
|
||||
@@map("counterparty_requests")
|
||||
}
|
||||
|
||||
// Модель для связей контрагентов
|
||||
model Counterparty {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Основная организация
|
||||
organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id])
|
||||
organizationId String
|
||||
|
||||
// Контрагент
|
||||
counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id])
|
||||
counterpartyId String
|
||||
counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id])
|
||||
organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id])
|
||||
|
||||
@@unique([organizationId, counterpartyId])
|
||||
@@map("counterparties")
|
||||
}
|
||||
|
||||
// Статус заявки на добавление в контрагенты
|
||||
enum CounterpartyRequestStatus {
|
||||
PENDING // Ожидает ответа
|
||||
ACCEPTED // Принята
|
||||
REJECTED // Отклонена
|
||||
CANCELLED // Отменена отправителем
|
||||
}
|
||||
|
||||
// Модель сообщений в мессенджере
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
content String? // Текст сообщения (nullable для медиа)
|
||||
content String?
|
||||
type MessageType @default(TEXT)
|
||||
voiceUrl String? // URL голосового файла в S3
|
||||
voiceDuration Int? // Длительность голосового сообщения в секундах
|
||||
fileUrl String? // URL файла/изображения в S3
|
||||
fileName String? // Оригинальное имя файла
|
||||
fileSize Int? // Размер файла в байтах
|
||||
fileType String? // MIME тип файла
|
||||
voiceUrl String?
|
||||
voiceDuration Int?
|
||||
fileUrl String?
|
||||
fileName String?
|
||||
fileSize Int?
|
||||
fileType String?
|
||||
isRead Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Отправитель
|
||||
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||
senderId String
|
||||
senderOrganization Organization @relation("SentMessages", fields: [senderOrganizationId], references: [id])
|
||||
senderOrganizationId String
|
||||
|
||||
// Получатель
|
||||
receiverOrganization Organization @relation("ReceivedMessages", fields: [receiverOrganizationId], references: [id])
|
||||
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([receiverOrganizationId, isRead])
|
||||
@@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 {
|
||||
TEXT
|
||||
VOICE
|
||||
@ -249,164 +334,17 @@ enum MessageType {
|
||||
FILE
|
||||
}
|
||||
|
||||
// Модель услуг (для фулфилмент центров)
|
||||
model Service {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
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 EmployeeStatus {
|
||||
ACTIVE
|
||||
VACATION
|
||||
SICK
|
||||
FIRED
|
||||
}
|
||||
|
||||
// Модель расходников (для фулфилмент центров)
|
||||
model Supply {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
price Decimal @db.Decimal(10,2) // Цена за единицу
|
||||
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")
|
||||
enum ScheduleStatus {
|
||||
WORK
|
||||
WEEKEND
|
||||
VACATION
|
||||
SICK
|
||||
ABSENT
|
||||
}
|
||||
|
147
src/app/api/upload-employee-document/route.ts
Normal file
147
src/app/api/upload-employee-document/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { EmployeesDashboard } from "@/components/employees/employees-dashboard"
|
||||
import { AuthGuard } from '@/components/auth-guard'
|
||||
import { EmployeesDashboard } from '@/components/employees/employees-dashboard'
|
||||
|
||||
export default function EmployeesPage() {
|
||||
return (
|
||||
|
559
src/components/employees/employee-edit-inline-form.tsx
Normal file
559
src/components/employees/employee-edit-inline-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
453
src/components/employees/employee-form.tsx
Normal file
453
src/components/employees/employee-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
521
src/components/employees/employee-inline-form.tsx
Normal file
521
src/components/employees/employee-inline-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -35,15 +35,26 @@ interface EmployeeSchedule {
|
||||
days: WorkDay[]
|
||||
}
|
||||
|
||||
// Моковые данные сотрудников для календаря
|
||||
const scheduleEmployees = [
|
||||
{ id: '1', name: 'Александр Петров', avatar: undefined },
|
||||
{ id: '2', name: 'Мария Иванова', avatar: undefined },
|
||||
{ id: '3', name: 'Дмитрий Сидоров', avatar: undefined },
|
||||
{ id: '4', name: 'Анна Козлова', avatar: undefined }
|
||||
]
|
||||
interface Employee {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
position: string
|
||||
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 [selectedEmployee, setSelectedEmployee] = useState<string>('all')
|
||||
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>
|
||||
{scheduleEmployees.map(emp => (
|
||||
{employees.map(emp => (
|
||||
<SelectItem key={emp.id} value={emp.id} className="text-white hover:bg-white/10">
|
||||
{emp.name}
|
||||
{emp.firstName} {emp.lastName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@ -325,7 +336,7 @@ export function EmployeeSchedule() {
|
||||
</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)
|
||||
|
||||
return (
|
||||
@ -335,14 +346,14 @@ export function EmployeeSchedule() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12 ring-2 ring-white/20">
|
||||
{employee.avatar ? (
|
||||
<AvatarImage src={employee.avatar} alt={employee.name} />
|
||||
<AvatarImage src={employee.avatar} alt={`${employee.firstName} ${employee.lastName}`} />
|
||||
) : null}
|
||||
<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>
|
||||
</Avatar>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,23 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { EmployeesList } from './employees-list'
|
||||
import { EmployeeSchedule } from './employee-schedule'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
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 {
|
||||
Users,
|
||||
Calendar,
|
||||
Search,
|
||||
Plus,
|
||||
FileText
|
||||
FileText,
|
||||
Edit,
|
||||
UserX,
|
||||
Phone,
|
||||
Mail,
|
||||
Download,
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Plane,
|
||||
Activity,
|
||||
Clock
|
||||
} 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() {
|
||||
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 (
|
||||
<div className="min-h-screen bg-gradient-smooth flex">
|
||||
@ -33,10 +251,15 @@ export function EmployeesDashboard() {
|
||||
<p className="text-white/70">Личные данные, табель работы и учет</p>
|
||||
</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" />
|
||||
Добавить сотрудника
|
||||
{showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
|
||||
</Button>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
@ -52,22 +275,37 @@ export function EmployeesDashboard() {
|
||||
</div>
|
||||
</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">
|
||||
<TabsList className="glass-card mb-6 grid w-full grid-cols-3">
|
||||
<Tabs defaultValue="combined" className="w-full">
|
||||
<TabsList className="glass-card mb-6 grid w-full grid-cols-2">
|
||||
<TabsTrigger
|
||||
value="list"
|
||||
value="combined"
|
||||
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
||||
>
|
||||
<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
|
||||
value="reports"
|
||||
@ -78,25 +316,548 @@ export function EmployeesDashboard() {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="list">
|
||||
<EmployeesList searchQuery={searchQuery} />
|
||||
</TabsContent>
|
||||
<TabsContent value="combined">
|
||||
<Card className="glass-card p-6">
|
||||
{(() => {
|
||||
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">
|
||||
<EmployeeSchedule />
|
||||
if (filteredEmployees.length === 0) {
|
||||
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 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">
|
||||
<FileText className="h-16 w-16 text-white/40 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Отчеты</h3>
|
||||
<p className="text-white/70 mb-4">
|
||||
Генерация отчетов по работе сотрудников
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<BarChart3 className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Нет данных для отчетов</h3>
|
||||
<p className="text-white/60 text-sm mb-4">
|
||||
Добавьте сотрудников, чтобы генерировать отчеты и аналитику
|
||||
</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>
|
||||
</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>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
@ -18,7 +19,9 @@ import {
|
||||
User,
|
||||
Briefcase,
|
||||
Save,
|
||||
X
|
||||
X,
|
||||
Trash2,
|
||||
UserX
|
||||
} from 'lucide-react'
|
||||
|
||||
// Интерфейс сотрудника
|
||||
@ -27,7 +30,7 @@ interface Employee {
|
||||
firstName: string
|
||||
lastName: string
|
||||
position: string
|
||||
department: string
|
||||
department?: string
|
||||
phone: string
|
||||
email: string
|
||||
avatar?: string
|
||||
@ -95,10 +98,12 @@ const mockEmployees: Employee[] = [
|
||||
|
||||
interface EmployeesListProps {
|
||||
searchQuery: string
|
||||
employees: Employee[]
|
||||
onEditEmployee: (employee: Employee) => void
|
||||
onDeleteEmployee: (employeeId: string) => void
|
||||
}
|
||||
|
||||
export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
||||
const [employees, setEmployees] = useState<Employee[]>(mockEmployees)
|
||||
export function EmployeesList({ searchQuery, employees, onEditEmployee, onDeleteEmployee }: EmployeesListProps) {
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
|
||||
@ -106,7 +111,7 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
||||
const filteredEmployees = employees.filter(employee =>
|
||||
`${employee.firstName} ${employee.lastName}`.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']) => {
|
||||
@ -133,18 +138,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
||||
}
|
||||
|
||||
const handleEditEmployee = (employee: Employee) => {
|
||||
setSelectedEmployee(employee)
|
||||
setIsEditModalOpen(true)
|
||||
onEditEmployee(employee)
|
||||
}
|
||||
|
||||
const handleSaveEmployee = () => {
|
||||
if (selectedEmployee) {
|
||||
setEmployees(prev =>
|
||||
prev.map(emp => emp.id === selectedEmployee.id ? selectedEmployee : emp)
|
||||
)
|
||||
setIsEditModalOpen(false)
|
||||
setSelectedEmployee(null)
|
||||
}
|
||||
const handleDeleteEmployee = (employee: Employee) => {
|
||||
onDeleteEmployee(employee.id)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -214,6 +212,7 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
||||
<h3 className="text-white font-semibold text-lg truncate">
|
||||
{employee.firstName} {employee.lastName}
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@ -222,6 +221,39 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
||||
>
|
||||
<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={() => handleDeleteEmployee(employee)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
Уволить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
onClick={handleSaveEmployee}
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="glass-button flex-1"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Сохранить
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@ -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)
|
||||
}
|
||||
`
|
@ -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
|
||||
}
|
||||
}
|
||||
`
|
@ -676,6 +676,72 @@ export const resolvers = {
|
||||
})
|
||||
|
||||
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: 'Ошибка при удалении из избранного'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Создать сотрудника
|
||||
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
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -43,6 +43,10 @@ export const typeDefs = gql`
|
||||
|
||||
# Избранные товары пользователя
|
||||
myFavorites: [Product!]!
|
||||
|
||||
# Сотрудники организации
|
||||
myEmployees: [Employee!]!
|
||||
employee(id: ID!): Employee
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@ -107,6 +111,12 @@ export const typeDefs = gql`
|
||||
# Работа с избранным
|
||||
addToFavorites(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!]
|
||||
}
|
||||
|
||||
# Типы для сотрудников
|
||||
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 скаляр
|
||||
scalar JSON
|
||||
`
|
Reference in New Issue
Block a user