Добавлены модели и функциональность для управления сотрудниками, включая создание, обновление и удаление сотрудников через 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 {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@ -10,29 +7,21 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модель пользователя
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
phone String @unique
|
phone String @unique
|
||||||
avatar String? // URL аватара в S3
|
avatar String?
|
||||||
managerName String? // Имя управляющего
|
managerName String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Связь с организацией
|
|
||||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
|
||||||
organizationId String?
|
organizationId String?
|
||||||
|
|
||||||
// SMS коды для авторизации
|
|
||||||
smsCodes SmsCode[]
|
|
||||||
|
|
||||||
// Отправленные сообщения
|
|
||||||
sentMessages Message[] @relation("SentMessages")
|
sentMessages Message[] @relation("SentMessages")
|
||||||
|
smsCodes SmsCode[]
|
||||||
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модель для SMS кодов
|
|
||||||
model SmsCode {
|
model SmsCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
code String
|
code String
|
||||||
@ -42,95 +31,62 @@ model SmsCode {
|
|||||||
attempts Int @default(0)
|
attempts Int @default(0)
|
||||||
maxAttempts Int @default(3)
|
maxAttempts Int @default(3)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Связь с пользователем
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
|
||||||
userId String?
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@map("sms_codes")
|
@@map("sms_codes")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модель организации
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
inn String @unique
|
inn String @unique
|
||||||
kpp String? // КПП
|
kpp String?
|
||||||
name String? // Краткое наименование
|
name String?
|
||||||
fullName String? // Полное наименование
|
fullName String?
|
||||||
ogrn String? // ОГРН организации
|
ogrn String?
|
||||||
ogrnDate DateTime? // Дата выдачи ОГРН
|
ogrnDate DateTime?
|
||||||
type OrganizationType
|
type OrganizationType
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
address String?
|
||||||
// Адрес организации
|
addressFull String?
|
||||||
address String? // Адрес одной строкой
|
status String?
|
||||||
addressFull String? // Полный адрес с индексом
|
actualityDate DateTime?
|
||||||
|
registrationDate DateTime?
|
||||||
// Статус организации
|
liquidationDate DateTime?
|
||||||
status String? // ACTIVE, LIQUIDATED и т.д.
|
managementName String?
|
||||||
actualityDate DateTime? // Дата последних изменений
|
managementPost String?
|
||||||
registrationDate DateTime? // Дата регистрации
|
opfCode String?
|
||||||
liquidationDate DateTime? // Дата ликвидации
|
opfFull String?
|
||||||
|
opfShort String?
|
||||||
// Руководитель
|
okato String?
|
||||||
managementName String? // ФИО или наименование руководителя
|
oktmo String?
|
||||||
managementPost String? // Должность руководителя
|
okpo String?
|
||||||
|
okved String?
|
||||||
// ОПФ (Организационно-правовая форма)
|
phones Json?
|
||||||
opfCode String? // Код ОКОПФ
|
emails Json?
|
||||||
opfFull String? // Полное название ОПФ
|
employeeCount Int?
|
||||||
opfShort String? // Краткое название ОПФ
|
revenue BigInt?
|
||||||
|
taxSystem String?
|
||||||
// Коды статистики
|
|
||||||
okato String? // Код ОКАТО
|
|
||||||
oktmo String? // Код ОКТМО
|
|
||||||
okpo String? // Код ОКПО
|
|
||||||
okved String? // Основной код ОКВЭД
|
|
||||||
|
|
||||||
// Контакты
|
|
||||||
phones Json? // Массив телефонов
|
|
||||||
emails Json? // Массив email адресов
|
|
||||||
|
|
||||||
// Финансовые данные
|
|
||||||
employeeCount Int? // Численность сотрудников
|
|
||||||
revenue BigInt? // Выручка
|
|
||||||
taxSystem String? // Система налогообложения
|
|
||||||
|
|
||||||
// Полные данные из DaData (для полноты)
|
|
||||||
dadataData Json?
|
dadataData Json?
|
||||||
|
|
||||||
// Связи
|
|
||||||
users User[]
|
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
|
carts Cart?
|
||||||
// Связи контрагентов
|
|
||||||
sentRequests CounterpartyRequest[] @relation("SentRequests")
|
|
||||||
receivedRequests CounterpartyRequest[] @relation("ReceivedRequests")
|
|
||||||
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
|
|
||||||
counterpartyOf Counterparty[] @relation("CounterpartyOf")
|
counterpartyOf Counterparty[] @relation("CounterpartyOf")
|
||||||
|
organizationCounterparties Counterparty[] @relation("OrganizationCounterparties")
|
||||||
// Сообщения
|
receivedRequests CounterpartyRequest[] @relation("ReceivedRequests")
|
||||||
sentMessages Message[] @relation("SentMessages")
|
sentRequests CounterpartyRequest[] @relation("SentRequests")
|
||||||
|
employees Employee[]
|
||||||
|
favorites Favorites[]
|
||||||
receivedMessages Message[] @relation("ReceivedMessages")
|
receivedMessages Message[] @relation("ReceivedMessages")
|
||||||
|
sentMessages Message[] @relation("SentMessages")
|
||||||
// Услуги и расходники (только для фулфилмент центров)
|
products Product[]
|
||||||
services Service[]
|
services Service[]
|
||||||
supplies Supply[]
|
supplies Supply[]
|
||||||
|
users User[]
|
||||||
// Товары (только для оптовиков)
|
|
||||||
products Product[]
|
|
||||||
|
|
||||||
// Корзины
|
|
||||||
carts Cart[]
|
|
||||||
|
|
||||||
// Избранные товары
|
|
||||||
favorites Favorites[]
|
|
||||||
|
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модель для API ключей маркетплейсов
|
|
||||||
model ApiKey {
|
model ApiKey {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
marketplace MarketplaceType
|
marketplace MarketplaceType
|
||||||
@ -138,110 +94,239 @@ model ApiKey {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Данные для валидации (например, информация о продавце)
|
|
||||||
validationData Json?
|
validationData Json?
|
||||||
|
|
||||||
// Связь с организацией
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id])
|
|
||||||
organizationId String
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
|
||||||
@@unique([organizationId, marketplace])
|
@@unique([organizationId, marketplace])
|
||||||
@@map("api_keys")
|
@@map("api_keys")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Тип организации
|
|
||||||
enum OrganizationType {
|
|
||||||
FULFILLMENT // Фулфилмент
|
|
||||||
SELLER // Селлер
|
|
||||||
LOGIST // Логистика
|
|
||||||
WHOLESALE // Оптовик
|
|
||||||
}
|
|
||||||
|
|
||||||
// Тип маркетплейса
|
|
||||||
enum MarketplaceType {
|
|
||||||
WILDBERRIES
|
|
||||||
OZON
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модель для заявок на добавление в контрагенты
|
|
||||||
model CounterpartyRequest {
|
model CounterpartyRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
status CounterpartyRequestStatus @default(PENDING)
|
status CounterpartyRequestStatus @default(PENDING)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Кто отправил заявку
|
|
||||||
sender Organization @relation("SentRequests", fields: [senderId], references: [id])
|
|
||||||
senderId String
|
senderId String
|
||||||
|
|
||||||
// Кому отправили заявку
|
|
||||||
receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id])
|
|
||||||
receiverId String
|
receiverId String
|
||||||
|
|
||||||
// Комментарий к заявке
|
|
||||||
message String?
|
message String?
|
||||||
|
receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id])
|
||||||
|
sender Organization @relation("SentRequests", fields: [senderId], references: [id])
|
||||||
|
|
||||||
@@unique([senderId, receiverId])
|
@@unique([senderId, receiverId])
|
||||||
@@map("counterparty_requests")
|
@@map("counterparty_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модель для связей контрагентов
|
|
||||||
model Counterparty {
|
model Counterparty {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Основная организация
|
|
||||||
organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id])
|
|
||||||
organizationId String
|
organizationId String
|
||||||
|
|
||||||
// Контрагент
|
|
||||||
counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id])
|
|
||||||
counterpartyId String
|
counterpartyId String
|
||||||
|
counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id])
|
||||||
|
organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id])
|
||||||
|
|
||||||
@@unique([organizationId, counterpartyId])
|
@@unique([organizationId, counterpartyId])
|
||||||
@@map("counterparties")
|
@@map("counterparties")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Статус заявки на добавление в контрагенты
|
|
||||||
enum CounterpartyRequestStatus {
|
|
||||||
PENDING // Ожидает ответа
|
|
||||||
ACCEPTED // Принята
|
|
||||||
REJECTED // Отклонена
|
|
||||||
CANCELLED // Отменена отправителем
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модель сообщений в мессенджере
|
|
||||||
model Message {
|
model Message {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
content String? // Текст сообщения (nullable для медиа)
|
content String?
|
||||||
type MessageType @default(TEXT)
|
type MessageType @default(TEXT)
|
||||||
voiceUrl String? // URL голосового файла в S3
|
voiceUrl String?
|
||||||
voiceDuration Int? // Длительность голосового сообщения в секундах
|
voiceDuration Int?
|
||||||
fileUrl String? // URL файла/изображения в S3
|
fileUrl String?
|
||||||
fileName String? // Оригинальное имя файла
|
fileName String?
|
||||||
fileSize Int? // Размер файла в байтах
|
fileSize Int?
|
||||||
fileType String? // MIME тип файла
|
fileType String?
|
||||||
isRead Boolean @default(false)
|
isRead Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Отправитель
|
|
||||||
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
|
||||||
senderId String
|
senderId String
|
||||||
senderOrganization Organization @relation("SentMessages", fields: [senderOrganizationId], references: [id])
|
|
||||||
senderOrganizationId String
|
senderOrganizationId String
|
||||||
|
|
||||||
// Получатель
|
|
||||||
receiverOrganization Organization @relation("ReceivedMessages", fields: [receiverOrganizationId], references: [id])
|
|
||||||
receiverOrganizationId String
|
receiverOrganizationId String
|
||||||
|
receiverOrganization Organization @relation("ReceivedMessages", fields: [receiverOrganizationId], references: [id])
|
||||||
|
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||||
|
senderOrganization Organization @relation("SentMessages", fields: [senderOrganizationId], references: [id])
|
||||||
|
|
||||||
@@index([senderOrganizationId, receiverOrganizationId, createdAt])
|
@@index([senderOrganizationId, receiverOrganizationId, createdAt])
|
||||||
@@index([receiverOrganizationId, isRead])
|
@@index([receiverOrganizationId, isRead])
|
||||||
@@map("messages")
|
@@map("messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Типы сообщений
|
model Service {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
imageUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("services")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Supply {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
quantity Int @default(0)
|
||||||
|
imageUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("supplies")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
products Product[]
|
||||||
|
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
article String
|
||||||
|
description String?
|
||||||
|
price Decimal @db.Decimal(12, 2)
|
||||||
|
quantity Int @default(0)
|
||||||
|
categoryId String?
|
||||||
|
brand String?
|
||||||
|
color String?
|
||||||
|
size String?
|
||||||
|
weight Decimal? @db.Decimal(8, 3)
|
||||||
|
dimensions String?
|
||||||
|
material String?
|
||||||
|
images Json @default("[]")
|
||||||
|
mainImage String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
organizationId String
|
||||||
|
cartItems CartItem[]
|
||||||
|
favorites Favorites[]
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([organizationId, article])
|
||||||
|
@@map("products")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Cart {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
items CartItem[]
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("carts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CartItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
cartId String
|
||||||
|
productId String
|
||||||
|
quantity Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([cartId, productId])
|
||||||
|
@@map("cart_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Favorites {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String
|
||||||
|
productId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([organizationId, productId])
|
||||||
|
@@map("favorites")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Employee {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
middleName String?
|
||||||
|
birthDate DateTime?
|
||||||
|
avatar String?
|
||||||
|
passportPhoto String?
|
||||||
|
passportSeries String?
|
||||||
|
passportNumber String?
|
||||||
|
passportIssued String?
|
||||||
|
passportDate DateTime?
|
||||||
|
address String?
|
||||||
|
position String
|
||||||
|
department String?
|
||||||
|
hireDate DateTime
|
||||||
|
salary Float?
|
||||||
|
status EmployeeStatus @default(ACTIVE)
|
||||||
|
phone String
|
||||||
|
email String?
|
||||||
|
telegram String?
|
||||||
|
whatsapp String?
|
||||||
|
emergencyContact String?
|
||||||
|
emergencyPhone String?
|
||||||
|
organizationId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
scheduleRecords EmployeeSchedule[]
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("employees")
|
||||||
|
}
|
||||||
|
|
||||||
|
model EmployeeSchedule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
date DateTime
|
||||||
|
status ScheduleStatus
|
||||||
|
hoursWorked Float?
|
||||||
|
notes String?
|
||||||
|
employeeId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([employeeId, date])
|
||||||
|
@@map("employee_schedules")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrganizationType {
|
||||||
|
FULFILLMENT
|
||||||
|
SELLER
|
||||||
|
LOGIST
|
||||||
|
WHOLESALE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MarketplaceType {
|
||||||
|
WILDBERRIES
|
||||||
|
OZON
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CounterpartyRequestStatus {
|
||||||
|
PENDING
|
||||||
|
ACCEPTED
|
||||||
|
REJECTED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
enum MessageType {
|
enum MessageType {
|
||||||
TEXT
|
TEXT
|
||||||
VOICE
|
VOICE
|
||||||
@ -249,164 +334,17 @@ enum MessageType {
|
|||||||
FILE
|
FILE
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модель услуг (для фулфилмент центров)
|
enum EmployeeStatus {
|
||||||
model Service {
|
ACTIVE
|
||||||
id String @id @default(cuid())
|
VACATION
|
||||||
name String
|
SICK
|
||||||
description String?
|
FIRED
|
||||||
price Decimal @db.Decimal(10,2) // Цена за единицу
|
|
||||||
imageUrl String? // URL фотографии в S3
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
// Связь с организацией
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
organizationId String
|
|
||||||
|
|
||||||
@@map("services")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модель расходников (для фулфилмент центров)
|
enum ScheduleStatus {
|
||||||
model Supply {
|
WORK
|
||||||
id String @id @default(cuid())
|
WEEKEND
|
||||||
name String
|
VACATION
|
||||||
description String?
|
SICK
|
||||||
price Decimal @db.Decimal(10,2) // Цена за единицу
|
ABSENT
|
||||||
quantity Int @default(0) // Количество в наличии
|
|
||||||
imageUrl String? // URL фотографии в S3
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
// Связь с организацией
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
organizationId String
|
|
||||||
|
|
||||||
@@map("supplies")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модель категорий товаров
|
|
||||||
model Category {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique // Название категории
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
// Связь с товарами
|
|
||||||
products Product[]
|
|
||||||
|
|
||||||
@@map("categories")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модель товаров (для оптовиков)
|
|
||||||
model Product {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
|
|
||||||
// Основные поля
|
|
||||||
name String // Название товара
|
|
||||||
article String // Артикул/номер записи
|
|
||||||
description String? // Описание
|
|
||||||
|
|
||||||
// Цена и количество
|
|
||||||
price Decimal @db.Decimal(12,2) // Цена за единицу
|
|
||||||
quantity Int @default(0) // Количество в наличии
|
|
||||||
|
|
||||||
// Основные характеристики
|
|
||||||
category Category? @relation(fields: [categoryId], references: [id])
|
|
||||||
categoryId String? // ID категории
|
|
||||||
brand String? // Бренд
|
|
||||||
|
|
||||||
// Дополнительные характеристики (необязательные)
|
|
||||||
color String? // Цвет
|
|
||||||
size String? // Размер
|
|
||||||
weight Decimal? @db.Decimal(8,3) // Вес в кг
|
|
||||||
dimensions String? // Габариты (ДxШxВ)
|
|
||||||
material String? // Материал
|
|
||||||
|
|
||||||
// Изображения (JSON массив URL-ов в S3)
|
|
||||||
images Json @default("[]") // Массив URL изображений
|
|
||||||
mainImage String? // URL главного изображения
|
|
||||||
|
|
||||||
// Статус товара
|
|
||||||
isActive Boolean @default(true) // Активен ли товар
|
|
||||||
|
|
||||||
// Временные метки
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
// Связь с организацией (только оптовики)
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
organizationId String
|
|
||||||
|
|
||||||
// Связь с элементами корзины
|
|
||||||
cartItems CartItem[]
|
|
||||||
|
|
||||||
// Избранные товары
|
|
||||||
favorites Favorites[]
|
|
||||||
|
|
||||||
// Уникальность артикула в рамках организации
|
|
||||||
@@unique([organizationId, article])
|
|
||||||
@@map("products")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модель корзины
|
|
||||||
model Cart {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
|
|
||||||
// Связь с организацией (только покупатель может иметь корзину)
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
organizationId String @unique // У каждой организации может быть только одна корзина
|
|
||||||
|
|
||||||
// Элементы корзины
|
|
||||||
items CartItem[]
|
|
||||||
|
|
||||||
// Временные метки
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@map("carts")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модель элемента корзины
|
|
||||||
model CartItem {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
|
|
||||||
// Связь с корзиной
|
|
||||||
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
|
|
||||||
cartId String
|
|
||||||
|
|
||||||
// Связь с товаром
|
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
||||||
productId String
|
|
||||||
|
|
||||||
// Количество товара в корзине
|
|
||||||
quantity Int @default(1)
|
|
||||||
|
|
||||||
// Временные метки
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
// Уникальность: один товар может быть только один раз в корзине
|
|
||||||
@@unique([cartId, productId])
|
|
||||||
@@map("cart_items")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модель избранных товаров
|
|
||||||
model Favorites {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
|
|
||||||
// Связь с организацией (пользователь может добавлять товары в избранное)
|
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
|
||||||
organizationId String
|
|
||||||
|
|
||||||
// Связь с товаром
|
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
||||||
productId String
|
|
||||||
|
|
||||||
// Временные метки
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
// Уникальность: один товар может быть только один раз в избранном у организации
|
|
||||||
@@unique([organizationId, productId])
|
|
||||||
@@map("favorites")
|
|
||||||
}
|
}
|
||||||
|
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 { AuthGuard } from '@/components/auth-guard'
|
||||||
import { EmployeesDashboard } from "@/components/employees/employees-dashboard"
|
import { EmployeesDashboard } from '@/components/employees/employees-dashboard'
|
||||||
|
|
||||||
export default function EmployeesPage() {
|
export default function EmployeesPage() {
|
||||||
return (
|
return (
|
||||||
|
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[]
|
days: WorkDay[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Моковые данные сотрудников для календаря
|
interface Employee {
|
||||||
const scheduleEmployees = [
|
id: string
|
||||||
{ id: '1', name: 'Александр Петров', avatar: undefined },
|
firstName: string
|
||||||
{ id: '2', name: 'Мария Иванова', avatar: undefined },
|
lastName: string
|
||||||
{ id: '3', name: 'Дмитрий Сидоров', avatar: undefined },
|
position: string
|
||||||
{ id: '4', name: 'Анна Козлова', avatar: undefined }
|
department?: string
|
||||||
]
|
phone: string
|
||||||
|
email: string
|
||||||
|
avatar?: string
|
||||||
|
hireDate: string
|
||||||
|
status: 'active' | 'vacation' | 'sick' | 'inactive'
|
||||||
|
salary: number
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
export function EmployeeSchedule() {
|
interface EmployeeScheduleProps {
|
||||||
|
employees: Employee[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeSchedule({ employees }: EmployeeScheduleProps) {
|
||||||
const [currentDate, setCurrentDate] = useState(new Date())
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
const [selectedEmployee, setSelectedEmployee] = useState<string>('all')
|
const [selectedEmployee, setSelectedEmployee] = useState<string>('all')
|
||||||
const [schedules, setSchedules] = useState<Record<string, EmployeeSchedule>>({})
|
const [schedules, setSchedules] = useState<Record<string, EmployeeSchedule>>({})
|
||||||
@ -295,9 +306,9 @@ export function EmployeeSchedule() {
|
|||||||
<SelectItem value="all" className="text-white hover:bg-white/10">
|
<SelectItem value="all" className="text-white hover:bg-white/10">
|
||||||
Все сотрудники
|
Все сотрудники
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{scheduleEmployees.map(emp => (
|
{employees.map(emp => (
|
||||||
<SelectItem key={emp.id} value={emp.id} className="text-white hover:bg-white/10">
|
<SelectItem key={emp.id} value={emp.id} className="text-white hover:bg-white/10">
|
||||||
{emp.name}
|
{emp.firstName} {emp.lastName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -325,7 +336,7 @@ export function EmployeeSchedule() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Календарь для каждого сотрудника */}
|
{/* Календарь для каждого сотрудника */}
|
||||||
{(selectedEmployee === 'all' ? scheduleEmployees : scheduleEmployees.filter(e => e.id === selectedEmployee)).map(employee => {
|
{(selectedEmployee === 'all' ? employees : employees.filter(e => e.id === selectedEmployee)).map(employee => {
|
||||||
const stats = getMonthStats(employee.id)
|
const stats = getMonthStats(employee.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -335,14 +346,14 @@ export function EmployeeSchedule() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar className="h-12 w-12 ring-2 ring-white/20">
|
<Avatar className="h-12 w-12 ring-2 ring-white/20">
|
||||||
{employee.avatar ? (
|
{employee.avatar ? (
|
||||||
<AvatarImage src={employee.avatar} alt={employee.name} />
|
<AvatarImage src={employee.avatar} alt={`${employee.firstName} ${employee.lastName}`} />
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold">
|
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold">
|
||||||
{employee.name.split(' ').map(n => n.charAt(0)).join('')}
|
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-semibold text-lg">{employee.name}</h3>
|
<h3 className="text-white font-semibold text-lg">{employee.firstName} {employee.lastName}</h3>
|
||||||
<p className="text-white/70 text-sm">Табель работы за {monthNames[currentMonth].toLowerCase()}</p>
|
<p className="text-white/70 text-sm">Табель работы за {monthNames[currentMonth].toLowerCase()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,23 +1,241 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { EmployeesList } from './employees-list'
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { EmployeeSchedule } from './employee-schedule'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { EmployeeForm } from './employee-form'
|
||||||
|
import { EmployeeInlineForm } from './employee-inline-form'
|
||||||
|
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||||
|
import { GET_MY_EMPLOYEES } from '@/graphql/queries'
|
||||||
|
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Calendar,
|
Calendar,
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
FileText
|
FileText,
|
||||||
|
Edit,
|
||||||
|
UserX,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Download,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Plane,
|
||||||
|
Activity,
|
||||||
|
Clock
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// Интерфейс сотрудника
|
||||||
|
interface Employee {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
middleName?: string
|
||||||
|
position: string
|
||||||
|
phone: string
|
||||||
|
email?: string
|
||||||
|
avatar?: string
|
||||||
|
hireDate: string
|
||||||
|
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
|
||||||
|
salary?: number
|
||||||
|
address?: string
|
||||||
|
birthDate?: string
|
||||||
|
passportSeries?: string
|
||||||
|
passportNumber?: string
|
||||||
|
passportIssued?: string
|
||||||
|
passportDate?: string
|
||||||
|
emergencyContact?: string
|
||||||
|
emergencyPhone?: string
|
||||||
|
telegram?: string
|
||||||
|
whatsapp?: string
|
||||||
|
passportPhoto?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export function EmployeesDashboard() {
|
export function EmployeesDashboard() {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [showEditForm, setShowEditForm] = useState(false)
|
||||||
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
||||||
|
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// GraphQL запросы и мутации
|
||||||
|
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES)
|
||||||
|
const [createEmployee] = useMutation(CREATE_EMPLOYEE)
|
||||||
|
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE)
|
||||||
|
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE)
|
||||||
|
|
||||||
|
const employees = data?.myEmployees || []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleEditEmployee = (employee: Employee) => {
|
||||||
|
setEditingEmployee(employee)
|
||||||
|
setShowEditForm(true)
|
||||||
|
setShowAddForm(false) // Закрываем форму добавления если открыта
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmployeeSaved = async (employeeData: Partial<Employee>) => {
|
||||||
|
try {
|
||||||
|
if (editingEmployee) {
|
||||||
|
// Обновление существующего сотрудника
|
||||||
|
const { data } = await updateEmployee({
|
||||||
|
variables: {
|
||||||
|
id: editingEmployee.id,
|
||||||
|
input: employeeData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (data?.updateEmployee?.success) {
|
||||||
|
toast.success('Сотрудник успешно обновлен')
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Добавление нового сотрудника
|
||||||
|
const { data } = await createEmployee({
|
||||||
|
variables: { input: employeeData }
|
||||||
|
})
|
||||||
|
if (data?.createEmployee?.success) {
|
||||||
|
toast.success('Сотрудник успешно добавлен')
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowEditForm(false)
|
||||||
|
setEditingEmployee(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving employee:', error)
|
||||||
|
toast.error('Ошибка при сохранении сотрудника')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateEmployee = async (employeeData: Partial<Employee>) => {
|
||||||
|
setCreateLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await createEmployee({
|
||||||
|
variables: { input: employeeData }
|
||||||
|
})
|
||||||
|
if (data?.createEmployee?.success) {
|
||||||
|
toast.success('Сотрудник успешно добавлен!')
|
||||||
|
setShowAddForm(false)
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating employee:', error)
|
||||||
|
toast.error('Ошибка при создании сотрудника')
|
||||||
|
} finally {
|
||||||
|
setCreateLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmployeeDeleted = async (employeeId: string) => {
|
||||||
|
try {
|
||||||
|
setDeletingEmployeeId(employeeId)
|
||||||
|
const { data } = await deleteEmployee({
|
||||||
|
variables: { id: employeeId }
|
||||||
|
})
|
||||||
|
if (data?.deleteEmployee) {
|
||||||
|
toast.success('Сотрудник успешно уволен')
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting employee:', error)
|
||||||
|
toast.error('Ошибка при увольнении сотрудника')
|
||||||
|
} finally {
|
||||||
|
setDeletingEmployeeId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToCSV = () => {
|
||||||
|
const csvContent = [
|
||||||
|
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
|
||||||
|
...employees.map((emp: Employee) => [
|
||||||
|
`${emp.firstName} ${emp.lastName}`,
|
||||||
|
emp.position,
|
||||||
|
emp.status === 'ACTIVE' ? 'Активен' :
|
||||||
|
emp.status === 'VACATION' ? 'В отпуске' :
|
||||||
|
emp.status === 'SICK' ? 'На больничном' : 'Уволен',
|
||||||
|
emp.salary?.toString() || '',
|
||||||
|
emp.phone,
|
||||||
|
emp.email || '',
|
||||||
|
new Date(emp.hireDate).toLocaleDateString('ru-RU')
|
||||||
|
])
|
||||||
|
].map(row => row.join(',')).join('\n')
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const link = document.createElement('a')
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
link.setAttribute('href', url)
|
||||||
|
link.setAttribute('download', `employees_report_${new Date().toISOString().split('T')[0]}.csv`)
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
toast.success('Отчет успешно экспортирован')
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateReport = () => {
|
||||||
|
const stats = {
|
||||||
|
total: employees.length,
|
||||||
|
active: employees.filter((e: Employee) => e.status === 'ACTIVE').length,
|
||||||
|
vacation: employees.filter((e: Employee) => e.status === 'VACATION').length,
|
||||||
|
sick: employees.filter((e: Employee) => e.status === 'SICK').length,
|
||||||
|
inactive: employees.filter((e: Employee) => e.status === 'FIRED').length,
|
||||||
|
avgSalary: Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportText = `
|
||||||
|
ОТЧЕТ ПО СОТРУДНИКАМ
|
||||||
|
Дата: ${new Date().toLocaleDateString('ru-RU')}
|
||||||
|
|
||||||
|
ОБЩАЯ СТАТИСТИКА:
|
||||||
|
• Всего сотрудников: ${stats.total}
|
||||||
|
• Активных: ${stats.active}
|
||||||
|
• В отпуске: ${stats.vacation}
|
||||||
|
• На больничном: ${stats.sick}
|
||||||
|
• Неактивных: ${stats.inactive}
|
||||||
|
• Средняя зарплата: ${stats.avgSalary.toLocaleString('ru-RU')} ₽
|
||||||
|
|
||||||
|
СПИСОК СОТРУДНИКОВ:
|
||||||
|
${employees.map((emp: Employee) =>
|
||||||
|
`• ${emp.firstName} ${emp.lastName} - ${emp.position}`
|
||||||
|
).join('\n')}
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8;' })
|
||||||
|
const link = document.createElement('a')
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
link.setAttribute('href', url)
|
||||||
|
link.setAttribute('download', `employees_summary_${new Date().toISOString().split('T')[0]}.txt`)
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
toast.success('Сводный отчет создан')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-smooth flex">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 ml-56 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-white text-xl">Загрузка сотрудников...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-smooth flex">
|
<div className="min-h-screen bg-gradient-smooth flex">
|
||||||
@ -33,10 +251,15 @@ export function EmployeesDashboard() {
|
|||||||
<p className="text-white/70">Личные данные, табель работы и учет</p>
|
<p className="text-white/70">Личные данные, табель работы и учет</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="glass-button">
|
<Button
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Добавить сотрудника
|
{showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Поиск */}
|
{/* Поиск */}
|
||||||
@ -52,22 +275,37 @@ export function EmployeesDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Форма добавления сотрудника */}
|
||||||
|
{showAddForm && (
|
||||||
|
<EmployeeInlineForm
|
||||||
|
onSave={handleCreateEmployee}
|
||||||
|
onCancel={() => setShowAddForm(false)}
|
||||||
|
isLoading={createLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Форма редактирования сотрудника */}
|
||||||
|
{showEditForm && editingEmployee && (
|
||||||
|
<EmployeeEditInlineForm
|
||||||
|
employee={editingEmployee}
|
||||||
|
onSave={handleEmployeeSaved}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowEditForm(false)
|
||||||
|
setEditingEmployee(null)
|
||||||
|
}}
|
||||||
|
isLoading={createLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Основной контент с вкладками */}
|
{/* Основной контент с вкладками */}
|
||||||
<Tabs defaultValue="list" className="w-full">
|
<Tabs defaultValue="combined" className="w-full">
|
||||||
<TabsList className="glass-card mb-6 grid w-full grid-cols-3">
|
<TabsList className="glass-card mb-6 grid w-full grid-cols-2">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="list"
|
value="combined"
|
||||||
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4 mr-2" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
Список сотрудников
|
Сотрудники и табель
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="schedule"
|
|
||||||
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Calendar className="h-4 w-4 mr-2" />
|
|
||||||
Табель работы
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="reports"
|
value="reports"
|
||||||
@ -78,25 +316,548 @@ export function EmployeesDashboard() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="list">
|
<TabsContent value="combined">
|
||||||
<EmployeesList searchQuery={searchQuery} />
|
<Card className="glass-card p-6">
|
||||||
</TabsContent>
|
{(() => {
|
||||||
|
const filteredEmployees = employees.filter(employee =>
|
||||||
|
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
employee.position.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(employee.department && employee.department.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
)
|
||||||
|
|
||||||
<TabsContent value="schedule">
|
if (filteredEmployees.length === 0) {
|
||||||
<EmployeeSchedule />
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Users className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">
|
||||||
|
{searchQuery ? 'Сотрудники не найдены' : 'У вас пока нет сотрудников'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/60 text-sm mb-4">
|
||||||
|
{searchQuery
|
||||||
|
? 'Попробуйте изменить критерии поиска'
|
||||||
|
: 'Добавьте первого сотрудника в вашу команду'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{!searchQuery && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить сотрудника
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Навигация по месяцам */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-white font-medium text-lg">Январь 2024</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
||||||
|
← Декабрь
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
||||||
|
Сегодня
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
||||||
|
Февраль →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Легенда точно как в гите */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-green-500/20 text-green-300 border-green-500/30">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Рабочий день</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-gray-500/20 text-gray-300 border-gray-500/30">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Выходной</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-blue-500/20 text-blue-300 border-blue-500/30">
|
||||||
|
<Plane className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Отпуск</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Больничный</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-red-500/20 text-red-300 border-red-500/30">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Прогул</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Объединенный список сотрудников с табелем */}
|
||||||
|
{filteredEmployees.map((employee) => {
|
||||||
|
// Генерируем календарные дни для текущего месяца
|
||||||
|
const currentDate = new Date()
|
||||||
|
const currentMonth = currentDate.getMonth()
|
||||||
|
const currentYear = currentDate.getFullYear()
|
||||||
|
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
|
||||||
|
|
||||||
|
// Создаем массив дней с моковыми данными табеля
|
||||||
|
const generateDayStatus = (day: number) => {
|
||||||
|
const date = new Date(currentYear, currentMonth, day)
|
||||||
|
const dayOfWeek = date.getDay()
|
||||||
|
|
||||||
|
// Выходные
|
||||||
|
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
|
||||||
|
|
||||||
|
// Некоторые случайные отпуска/больничные для демонстрации
|
||||||
|
if ([15, 16].includes(day)) return 'vacation'
|
||||||
|
if ([10].includes(day)) return 'sick'
|
||||||
|
if ([22].includes(day)) return 'absent'
|
||||||
|
|
||||||
|
return 'work'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayClass = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'work': return 'bg-green-500/30 border-green-500/50'
|
||||||
|
case 'weekend': return 'bg-gray-500/30 border-gray-500/50'
|
||||||
|
case 'vacation': return 'bg-blue-500/30 border-blue-500/50'
|
||||||
|
case 'sick': return 'bg-yellow-500/30 border-yellow-500/50'
|
||||||
|
case 'absent': return 'bg-red-500/30 border-red-500/50'
|
||||||
|
default: return 'bg-white/10 border-white/20'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем статистику
|
||||||
|
const stats = {
|
||||||
|
workDays: 0,
|
||||||
|
vacationDays: 0,
|
||||||
|
sickDays: 0,
|
||||||
|
absentDays: 0,
|
||||||
|
totalHours: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const status = generateDayStatus(day)
|
||||||
|
switch (status) {
|
||||||
|
case 'work':
|
||||||
|
stats.workDays++
|
||||||
|
stats.totalHours += 8
|
||||||
|
break
|
||||||
|
case 'vacation':
|
||||||
|
stats.vacationDays++
|
||||||
|
break
|
||||||
|
case 'sick':
|
||||||
|
stats.sickDays++
|
||||||
|
break
|
||||||
|
case 'absent':
|
||||||
|
stats.absentDays++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={employee.id} className="glass-card p-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
{/* Информация о сотруднике */}
|
||||||
|
<div className="lg:w-80 flex-shrink-0">
|
||||||
|
<div className="flex items-start space-x-4 mb-4">
|
||||||
|
<Avatar className="h-16 w-16 ring-2 ring-white/20">
|
||||||
|
{employee.avatar ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={employee.avatar}
|
||||||
|
alt={`${employee.firstName} ${employee.lastName}`}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Ошибка загрузки аватара:', employee.avatar);
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
onLoad={() => console.log('Аватар загружен успешно:', employee.avatar)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold text-lg">
|
||||||
|
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-white font-semibold text-lg truncate">
|
||||||
|
{employee.firstName} {employee.lastName}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
|
||||||
|
onClick={() => handleEditEmployee(employee)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<UserX className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="glass-card border-white/10">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-white/70">
|
||||||
|
Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}?
|
||||||
|
Это действие нельзя отменить.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
|
||||||
|
Отмена
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleEmployeeDeleted(employee.id)}
|
||||||
|
disabled={deletingEmployeeId === employee.id}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
{deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-purple-300 font-medium mb-1">{employee.position}</p>
|
||||||
|
{employee.department && (
|
||||||
|
<p className="text-white/60 text-sm mb-3">{employee.department}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center text-white/70 text-sm">
|
||||||
|
<Phone className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate">{employee.phone}</span>
|
||||||
|
</div>
|
||||||
|
{employee.email && (
|
||||||
|
<div className="flex items-center text-white/70 text-sm">
|
||||||
|
<Mail className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate">{employee.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика за месяц */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="text-center p-3 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-green-400 font-semibold text-lg">{stats.workDays}</p>
|
||||||
|
<p className="text-white/60 text-xs">Рабочих дней</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-blue-400 font-semibold text-lg">{stats.vacationDays}</p>
|
||||||
|
<p className="text-white/60 text-xs">Отпуск</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-yellow-400 font-semibold text-lg">{stats.sickDays}</p>
|
||||||
|
<p className="text-white/60 text-xs">Больничный</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-white font-semibold text-lg">{stats.totalHours}ч</p>
|
||||||
|
<p className="text-white/60 text-xs">Всего часов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Табель работы 1 в 1 как в гите */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Сетка календаря - точно как в гите */}
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{/* Заголовки дней недели */}
|
||||||
|
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
|
||||||
|
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Дни месяца */}
|
||||||
|
{(() => {
|
||||||
|
// Точная логика из гита
|
||||||
|
const calendarDays: (number | null)[] = []
|
||||||
|
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay()
|
||||||
|
const startOffset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1
|
||||||
|
|
||||||
|
// Добавляем пустые ячейки для выравнивания первой недели
|
||||||
|
for (let i = 0; i < startOffset; i++) {
|
||||||
|
calendarDays.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем дни месяца
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
calendarDays.push(day)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функции статуса и стилей точно как в гите
|
||||||
|
const getDayStatus = (day: number) => {
|
||||||
|
const date = new Date(currentYear, currentMonth, day)
|
||||||
|
const dayOfWeek = date.getDay()
|
||||||
|
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
|
||||||
|
if ([15, 16].includes(day)) return 'vacation'
|
||||||
|
if ([10].includes(day)) return 'sick'
|
||||||
|
if ([22].includes(day)) return 'absent'
|
||||||
|
return 'work'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCellStyle = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'work':
|
||||||
|
return 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||||
|
case 'weekend':
|
||||||
|
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||||||
|
case 'vacation':
|
||||||
|
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
|
||||||
|
case 'sick':
|
||||||
|
return 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30'
|
||||||
|
case 'absent':
|
||||||
|
return 'bg-red-500/20 text-red-300 border-red-500/30'
|
||||||
|
default:
|
||||||
|
return 'bg-white/10 text-white/70 border-white/20'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'work':
|
||||||
|
return <CheckCircle className="h-3 w-3" />
|
||||||
|
case 'weekend':
|
||||||
|
return <Clock className="h-3 w-3" />
|
||||||
|
case 'vacation':
|
||||||
|
return <Plane className="h-3 w-3" />
|
||||||
|
case 'sick':
|
||||||
|
return <Activity className="h-3 w-3" />
|
||||||
|
case 'absent':
|
||||||
|
return <XCircle className="h-3 w-3" />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return calendarDays.map((day, index) => {
|
||||||
|
if (day === null) {
|
||||||
|
return <div key={`empty-${index}`} className="p-2"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getDayStatus(day)
|
||||||
|
const hours = status === 'work' ? 8 : status === 'vacation' || status === 'sick' ? 8 : 0
|
||||||
|
const isToday = new Date().getDate() === day &&
|
||||||
|
new Date().getMonth() === currentMonth &&
|
||||||
|
new Date().getFullYear() === currentYear
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${employee.id}-${day}`}
|
||||||
|
className={`
|
||||||
|
relative p-2 min-h-[60px] border rounded-lg cursor-pointer
|
||||||
|
transition-all duration-200 hover:scale-105
|
||||||
|
${getCellStyle(status)}
|
||||||
|
${isToday ? 'ring-2 ring-white/50' : ''}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
// Циклично переключаем статусы - точно как в гите
|
||||||
|
// const statuses = ['work', 'weekend', 'vacation', 'sick', 'absent']
|
||||||
|
// const currentIndex = statuses.indexOf(status)
|
||||||
|
// const nextStatus = statuses[(currentIndex + 1) % statuses.length]
|
||||||
|
// changeDayStatus(employee.id, day, nextStatus)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
{getStatusIcon(status)}
|
||||||
|
<span className="font-semibold text-sm">{day}</span>
|
||||||
|
</div>
|
||||||
|
{hours > 0 && (
|
||||||
|
<span className="text-xs opacity-80">{hours}ч</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isToday && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reports">
|
<TabsContent value="reports">
|
||||||
<Card className="glass-card p-8">
|
{employees.length === 0 ? (
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<FileText className="h-16 w-16 text-white/40 mx-auto mb-4" />
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<h3 className="text-xl font-semibold text-white mb-2">Отчеты</h3>
|
<BarChart3 className="h-8 w-8 text-white/40" />
|
||||||
<p className="text-white/70 mb-4">
|
</div>
|
||||||
Генерация отчетов по работе сотрудников
|
<h3 className="text-lg font-medium text-white mb-2">Нет данных для отчетов</h3>
|
||||||
|
<p className="text-white/60 text-sm mb-4">
|
||||||
|
Добавьте сотрудников, чтобы генерировать отчеты и аналитику
|
||||||
</p>
|
</p>
|
||||||
<p className="text-white/50 text-sm">Функция в разработке</p>
|
<Button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить сотрудника
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Статистические карточки */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-sm">Всего сотрудников</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{employees.length}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="h-8 w-8 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-sm">Активных</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">
|
||||||
|
{employees.filter((e: Employee) => e.status === 'ACTIVE').length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BarChart3 className="h-8 w-8 text-green-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-sm">Средняя зарплата</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-400">
|
||||||
|
{employees.length > 0
|
||||||
|
? Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length).toLocaleString('ru-RU')
|
||||||
|
: '0'} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FileText className="h-8 w-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-sm">Отделов</p>
|
||||||
|
<p className="text-2xl font-bold text-orange-400">
|
||||||
|
{new Set(employees.map((e: Employee) => e.department).filter(Boolean)).size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Calendar className="h-8 w-8 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Экспорт отчетов */}
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<h3 className="text-white font-medium mb-4 flex items-center gap-2">
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
Экспорт отчетов
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-white/80 font-medium">Детальный отчет (CSV)</h4>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
Полная информация о всех сотрудниках в формате таблицы для Excel/Google Sheets
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={exportToCSV}
|
||||||
|
className="w-full glass-button"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Скачать CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-white/80 font-medium">Сводный отчет (TXT)</h4>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
Краткая статистика и список сотрудников в текстовом формате
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={generateReport}
|
||||||
|
className="w-full glass-button"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
Создать отчет
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Аналитика по отделам */}
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<h3 className="text-white font-medium mb-4">Распределение по отделам</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from(new Set(employees.map(e => e.department).filter(Boolean))).map(dept => {
|
||||||
|
const deptEmployees = employees.filter(e => e.department === dept)
|
||||||
|
const percentage = Math.round((deptEmployees.length / employees.length) * 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dept} className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-white/80 text-sm">{dept}</span>
|
||||||
|
<span className="text-white/60 text-xs">{deptEmployees.length} чел. ({percentage}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
@ -18,7 +19,9 @@ import {
|
|||||||
User,
|
User,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Save,
|
Save,
|
||||||
X
|
X,
|
||||||
|
Trash2,
|
||||||
|
UserX
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Интерфейс сотрудника
|
// Интерфейс сотрудника
|
||||||
@ -27,7 +30,7 @@ interface Employee {
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
position: string
|
position: string
|
||||||
department: string
|
department?: string
|
||||||
phone: string
|
phone: string
|
||||||
email: string
|
email: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
@ -95,10 +98,12 @@ const mockEmployees: Employee[] = [
|
|||||||
|
|
||||||
interface EmployeesListProps {
|
interface EmployeesListProps {
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
|
employees: Employee[]
|
||||||
|
onEditEmployee: (employee: Employee) => void
|
||||||
|
onDeleteEmployee: (employeeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
export function EmployeesList({ searchQuery, employees, onEditEmployee, onDeleteEmployee }: EmployeesListProps) {
|
||||||
const [employees, setEmployees] = useState<Employee[]>(mockEmployees)
|
|
||||||
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null)
|
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null)
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||||
|
|
||||||
@ -106,7 +111,7 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
|||||||
const filteredEmployees = employees.filter(employee =>
|
const filteredEmployees = employees.filter(employee =>
|
||||||
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
employee.position.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
employee.position.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
employee.department.toLowerCase().includes(searchQuery.toLowerCase())
|
(employee.department && employee.department.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
)
|
)
|
||||||
|
|
||||||
const getStatusBadge = (status: Employee['status']) => {
|
const getStatusBadge = (status: Employee['status']) => {
|
||||||
@ -133,18 +138,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditEmployee = (employee: Employee) => {
|
const handleEditEmployee = (employee: Employee) => {
|
||||||
setSelectedEmployee(employee)
|
onEditEmployee(employee)
|
||||||
setIsEditModalOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveEmployee = () => {
|
const handleDeleteEmployee = (employee: Employee) => {
|
||||||
if (selectedEmployee) {
|
onDeleteEmployee(employee.id)
|
||||||
setEmployees(prev =>
|
|
||||||
prev.map(emp => emp.id === selectedEmployee.id ? selectedEmployee : emp)
|
|
||||||
)
|
|
||||||
setIsEditModalOpen(false)
|
|
||||||
setSelectedEmployee(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -214,6 +212,7 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
|||||||
<h3 className="text-white font-semibold text-lg truncate">
|
<h3 className="text-white font-semibold text-lg truncate">
|
||||||
{employee.firstName} {employee.lastName}
|
{employee.firstName} {employee.lastName}
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -222,6 +221,39 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
|||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<UserX className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="glass-card border-white/10">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-white/70">
|
||||||
|
Вы уверены, что хотите уволить {employee.firstName} {employee.lastName}?
|
||||||
|
Это действие изменит статус сотрудника на "Неактивен" и удалит его из активного списка.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
|
||||||
|
Отмена
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDeleteEmployee(employee)}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
Уволить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-purple-300 font-medium mb-1">{employee.position}</p>
|
<p className="text-purple-300 font-medium mb-1">{employee.position}</p>
|
||||||
@ -389,11 +421,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
|
|||||||
|
|
||||||
<div className="flex gap-2 pt-4">
|
<div className="flex gap-2 pt-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSaveEmployee}
|
onClick={() => setIsEditModalOpen(false)}
|
||||||
className="glass-button flex-1"
|
className="glass-button flex-1"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
Сохранить
|
Закрыть
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
@ -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)
|
return favorites.map(favorite => favorite.product)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Сотрудники организации
|
||||||
|
myEmployees: async (_: unknown, __: unknown, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Доступно только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
const employees = await prisma.employee.findMany({
|
||||||
|
where: { organizationId: currentUser.organization.id },
|
||||||
|
include: {
|
||||||
|
organization: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
return employees
|
||||||
|
},
|
||||||
|
|
||||||
|
// Получение сотрудника по ID
|
||||||
|
employee: async (_: unknown, args: { id: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Доступно только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await prisma.employee.findFirst({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organization: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return employee
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -3041,6 +3107,206 @@ export const resolvers = {
|
|||||||
message: 'Ошибка при удалении из избранного'
|
message: 'Ошибка при удалении из избранного'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Создать сотрудника
|
||||||
|
createEmployee: async (_: unknown, args: { input: any }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Доступно только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const employee = await prisma.employee.create({
|
||||||
|
data: {
|
||||||
|
...args.input,
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
birthDate: args.input.birthDate ? new Date(args.input.birthDate) : undefined,
|
||||||
|
passportDate: args.input.passportDate ? new Date(args.input.passportDate) : undefined,
|
||||||
|
hireDate: new Date(args.input.hireDate)
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organization: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Сотрудник успешно добавлен',
|
||||||
|
employee
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating employee:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при создании сотрудника'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновить сотрудника
|
||||||
|
updateEmployee: async (_: unknown, args: { id: string; input: any }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Доступно только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const employee = await prisma.employee.update({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...args.input,
|
||||||
|
birthDate: args.input.birthDate ? new Date(args.input.birthDate) : undefined,
|
||||||
|
passportDate: args.input.passportDate ? new Date(args.input.passportDate) : undefined,
|
||||||
|
hireDate: args.input.hireDate ? new Date(args.input.hireDate) : undefined
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organization: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Сотрудник успешно обновлен',
|
||||||
|
employee
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating employee:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Ошибка при обновлении сотрудника'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удалить сотрудника
|
||||||
|
deleteEmployee: async (_: unknown, args: { id: string }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Доступно только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.employee.delete({
|
||||||
|
where: {
|
||||||
|
id: args.id,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting employee:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновить табель сотрудника
|
||||||
|
updateEmployeeSchedule: async (_: unknown, args: { input: any }, context: Context) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError('Требуется авторизация', {
|
||||||
|
extensions: { code: 'UNAUTHENTICATED' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError('У пользователя нет организации')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.organization.type !== 'FULFILLMENT') {
|
||||||
|
throw new GraphQLError('Доступно только для фулфилмент центров')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверяем что сотрудник принадлежит организации
|
||||||
|
const employee = await prisma.employee.findFirst({
|
||||||
|
where: {
|
||||||
|
id: args.input.employeeId,
|
||||||
|
organizationId: currentUser.organization.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
throw new GraphQLError('Сотрудник не найден')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем или обновляем запись табеля
|
||||||
|
await prisma.employeeSchedule.upsert({
|
||||||
|
where: {
|
||||||
|
employeeId_date: {
|
||||||
|
employeeId: args.input.employeeId,
|
||||||
|
date: new Date(args.input.date)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
employeeId: args.input.employeeId,
|
||||||
|
date: new Date(args.input.date),
|
||||||
|
status: args.input.status,
|
||||||
|
hoursWorked: args.input.hoursWorked,
|
||||||
|
notes: args.input.notes
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: args.input.status,
|
||||||
|
hoursWorked: args.input.hoursWorked,
|
||||||
|
notes: args.input.notes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating employee schedule:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -3120,5 +3386,61 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
return parent.updatedAt
|
return parent.updatedAt
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Employee: {
|
||||||
|
birthDate: (parent: { birthDate?: Date | string | null }) => {
|
||||||
|
if (!parent.birthDate) return null
|
||||||
|
if (parent.birthDate instanceof Date) {
|
||||||
|
return parent.birthDate.toISOString()
|
||||||
|
}
|
||||||
|
return parent.birthDate
|
||||||
|
},
|
||||||
|
passportDate: (parent: { passportDate?: Date | string | null }) => {
|
||||||
|
if (!parent.passportDate) return null
|
||||||
|
if (parent.passportDate instanceof Date) {
|
||||||
|
return parent.passportDate.toISOString()
|
||||||
|
}
|
||||||
|
return parent.passportDate
|
||||||
|
},
|
||||||
|
hireDate: (parent: { hireDate: Date | string }) => {
|
||||||
|
if (parent.hireDate instanceof Date) {
|
||||||
|
return parent.hireDate.toISOString()
|
||||||
|
}
|
||||||
|
return parent.hireDate
|
||||||
|
},
|
||||||
|
createdAt: (parent: { createdAt: Date | string }) => {
|
||||||
|
if (parent.createdAt instanceof Date) {
|
||||||
|
return parent.createdAt.toISOString()
|
||||||
|
}
|
||||||
|
return parent.createdAt
|
||||||
|
},
|
||||||
|
updatedAt: (parent: { updatedAt: Date | string }) => {
|
||||||
|
if (parent.updatedAt instanceof Date) {
|
||||||
|
return parent.updatedAt.toISOString()
|
||||||
|
}
|
||||||
|
return parent.updatedAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
EmployeeSchedule: {
|
||||||
|
date: (parent: { date: Date | string }) => {
|
||||||
|
if (parent.date instanceof Date) {
|
||||||
|
return parent.date.toISOString()
|
||||||
|
}
|
||||||
|
return parent.date
|
||||||
|
},
|
||||||
|
createdAt: (parent: { createdAt: Date | string }) => {
|
||||||
|
if (parent.createdAt instanceof Date) {
|
||||||
|
return parent.createdAt.toISOString()
|
||||||
|
}
|
||||||
|
return parent.createdAt
|
||||||
|
},
|
||||||
|
updatedAt: (parent: { updatedAt: Date | string }) => {
|
||||||
|
if (parent.updatedAt instanceof Date) {
|
||||||
|
return parent.updatedAt.toISOString()
|
||||||
|
}
|
||||||
|
return parent.updatedAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -43,6 +43,10 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Избранные товары пользователя
|
# Избранные товары пользователя
|
||||||
myFavorites: [Product!]!
|
myFavorites: [Product!]!
|
||||||
|
|
||||||
|
# Сотрудники организации
|
||||||
|
myEmployees: [Employee!]!
|
||||||
|
employee(id: ID!): Employee
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@ -107,6 +111,12 @@ export const typeDefs = gql`
|
|||||||
# Работа с избранным
|
# Работа с избранным
|
||||||
addToFavorites(productId: ID!): FavoritesResponse!
|
addToFavorites(productId: ID!): FavoritesResponse!
|
||||||
removeFromFavorites(productId: ID!): FavoritesResponse!
|
removeFromFavorites(productId: ID!): FavoritesResponse!
|
||||||
|
|
||||||
|
# Работа с сотрудниками
|
||||||
|
createEmployee(input: CreateEmployeeInput!): EmployeeResponse!
|
||||||
|
updateEmployee(id: ID!, input: UpdateEmployeeInput!): EmployeeResponse!
|
||||||
|
deleteEmployee(id: ID!): Boolean!
|
||||||
|
updateEmployeeSchedule(input: UpdateScheduleInput!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
# Типы данных
|
# Типы данных
|
||||||
@ -470,6 +480,132 @@ export const typeDefs = gql`
|
|||||||
favorites: [Product!]
|
favorites: [Product!]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Типы для сотрудников
|
||||||
|
type Employee {
|
||||||
|
id: ID!
|
||||||
|
firstName: String!
|
||||||
|
lastName: String!
|
||||||
|
middleName: String
|
||||||
|
birthDate: String
|
||||||
|
avatar: String
|
||||||
|
passportPhoto: String
|
||||||
|
passportSeries: String
|
||||||
|
passportNumber: String
|
||||||
|
passportIssued: String
|
||||||
|
passportDate: String
|
||||||
|
address: String
|
||||||
|
position: String!
|
||||||
|
department: String
|
||||||
|
hireDate: String!
|
||||||
|
salary: Float
|
||||||
|
status: EmployeeStatus!
|
||||||
|
phone: String!
|
||||||
|
email: String
|
||||||
|
telegram: String
|
||||||
|
whatsapp: String
|
||||||
|
emergencyContact: String
|
||||||
|
emergencyPhone: String
|
||||||
|
scheduleRecords: [EmployeeSchedule!]!
|
||||||
|
organization: Organization!
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EmployeeStatus {
|
||||||
|
ACTIVE
|
||||||
|
VACATION
|
||||||
|
SICK
|
||||||
|
FIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmployeeSchedule {
|
||||||
|
id: ID!
|
||||||
|
date: String!
|
||||||
|
status: ScheduleStatus!
|
||||||
|
hoursWorked: Float
|
||||||
|
notes: String
|
||||||
|
employee: Employee!
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScheduleStatus {
|
||||||
|
WORK
|
||||||
|
WEEKEND
|
||||||
|
VACATION
|
||||||
|
SICK
|
||||||
|
ABSENT
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateEmployeeInput {
|
||||||
|
firstName: String!
|
||||||
|
lastName: String!
|
||||||
|
middleName: String
|
||||||
|
birthDate: String
|
||||||
|
avatar: String
|
||||||
|
passportPhoto: String
|
||||||
|
passportSeries: String
|
||||||
|
passportNumber: String
|
||||||
|
passportIssued: String
|
||||||
|
passportDate: String
|
||||||
|
address: String
|
||||||
|
position: String!
|
||||||
|
department: String
|
||||||
|
hireDate: String!
|
||||||
|
salary: Float
|
||||||
|
phone: String!
|
||||||
|
email: String
|
||||||
|
telegram: String
|
||||||
|
whatsapp: String
|
||||||
|
emergencyContact: String
|
||||||
|
emergencyPhone: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateEmployeeInput {
|
||||||
|
firstName: String
|
||||||
|
lastName: String
|
||||||
|
middleName: String
|
||||||
|
birthDate: String
|
||||||
|
avatar: String
|
||||||
|
passportPhoto: String
|
||||||
|
passportSeries: String
|
||||||
|
passportNumber: String
|
||||||
|
passportIssued: String
|
||||||
|
passportDate: String
|
||||||
|
address: String
|
||||||
|
position: String
|
||||||
|
department: String
|
||||||
|
hireDate: String
|
||||||
|
salary: Float
|
||||||
|
status: EmployeeStatus
|
||||||
|
phone: String
|
||||||
|
email: String
|
||||||
|
telegram: String
|
||||||
|
whatsapp: String
|
||||||
|
emergencyContact: String
|
||||||
|
emergencyPhone: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateScheduleInput {
|
||||||
|
employeeId: ID!
|
||||||
|
date: String!
|
||||||
|
status: ScheduleStatus!
|
||||||
|
hoursWorked: Float
|
||||||
|
notes: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmployeeResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
employee: Employee
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmployeesResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
employees: [Employee!]!
|
||||||
|
}
|
||||||
|
|
||||||
# JSON скаляр
|
# JSON скаляр
|
||||||
scalar JSON
|
scalar JSON
|
||||||
`
|
`
|
Reference in New Issue
Block a user