From d3613647164cb69fe27483c66a70e10bd7157f21 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Thu, 17 Jul 2025 23:55:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8,=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20GraphQL.=20=D0=9E=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D1=81=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B8?= =?UTF-8?q?=20=D0=B8=D1=85=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=20=D0=B2=D0=B7=D0=B0=D0=B8=D0=BC=D0=BE=D0=B4=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D1=8F=20=D1=81=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BC.=20?= =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20=D0=BE=D1=82=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B2=20CSV=20=D0=B8=20TXT=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B0=D1=85,=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D1=8B=20=D0=B8=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 650 +++++++------- src/app/api/upload-employee-document/route.ts | 147 ++++ src/app/employees/page.tsx | 4 +- .../employees/employee-edit-inline-form.tsx | 559 ++++++++++++ src/components/employees/employee-form.tsx | 453 ++++++++++ .../employees/employee-inline-form.tsx | 521 +++++++++++ .../employees/employee-schedule.tsx | 39 +- .../employees/employees-dashboard.tsx | 823 +++++++++++++++++- src/components/employees/employees-list.tsx | 82 +- src/graphql/mutations.ts | 74 ++ src/graphql/queries.ts | 62 ++ src/graphql/resolvers.ts | 322 +++++++ src/graphql/typedefs.ts | 136 +++ 13 files changed, 3444 insertions(+), 428 deletions(-) create mode 100644 src/app/api/upload-employee-document/route.ts create mode 100644 src/components/employees/employee-edit-inline-form.tsx create mode 100644 src/components/employees/employee-form.tsx create mode 100644 src/components/employees/employee-inline-form.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 81e0881..2355bf7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client-js" } @@ -10,238 +7,326 @@ datasource db { url = env("DATABASE_URL") } -// Модель пользователя model User { - id String @id @default(cuid()) - phone String @unique - avatar String? // URL аватара в S3 - managerName String? // Имя управляющего - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Связь с организацией - organization Organization? @relation(fields: [organizationId], references: [id]) + id String @id @default(cuid()) + phone String @unique + avatar String? + managerName String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt organizationId String? - - // SMS коды для авторизации - smsCodes SmsCode[] - - // Отправленные сообщения - sentMessages Message[] @relation("SentMessages") - + sentMessages Message[] @relation("SentMessages") + smsCodes SmsCode[] + organization Organization? @relation(fields: [organizationId], references: [id]) + @@map("users") } -// Модель для SMS кодов model SmsCode { - id String @id @default(cuid()) - code String - phone String - expiresAt DateTime - isUsed Boolean @default(false) - attempts Int @default(0) - maxAttempts Int @default(3) - createdAt DateTime @default(now()) - - // Связь с пользователем - user User? @relation(fields: [userId], references: [id]) - userId String? - + id String @id @default(cuid()) + code String + phone String + expiresAt DateTime + isUsed Boolean @default(false) + attempts Int @default(0) + maxAttempts Int @default(3) + createdAt DateTime @default(now()) + userId String? + user User? @relation(fields: [userId], references: [id]) + @@map("sms_codes") } -// Модель организации model Organization { - id String @id @default(cuid()) - inn String @unique - kpp String? // КПП - name String? // Краткое наименование - fullName String? // Полное наименование - ogrn String? // ОГРН организации - ogrnDate DateTime? // Дата выдачи ОГРН - type OrganizationType - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Адрес организации - address String? // Адрес одной строкой - addressFull String? // Полный адрес с индексом - - // Статус организации - status String? // ACTIVE, LIQUIDATED и т.д. - actualityDate DateTime? // Дата последних изменений - registrationDate DateTime? // Дата регистрации - liquidationDate DateTime? // Дата ликвидации - - // Руководитель - managementName String? // ФИО или наименование руководителя - managementPost String? // Должность руководителя - - // ОПФ (Организационно-правовая форма) - opfCode String? // Код ОКОПФ - opfFull String? // Полное название ОПФ - opfShort String? // Краткое название ОПФ - - // Коды статистики - okato String? // Код ОКАТО - oktmo String? // Код ОКТМО - okpo String? // Код ОКПО - okved String? // Основной код ОКВЭД - - // Контакты - phones Json? // Массив телефонов - emails Json? // Массив email адресов - - // Финансовые данные - employeeCount Int? // Численность сотрудников - revenue BigInt? // Выручка - taxSystem String? // Система налогообложения - - // Полные данные из DaData (для полноты) - dadataData Json? - - // Связи - users User[] - apiKeys ApiKey[] - - // Связи контрагентов - sentRequests CounterpartyRequest[] @relation("SentRequests") - receivedRequests CounterpartyRequest[] @relation("ReceivedRequests") - organizationCounterparties Counterparty[] @relation("OrganizationCounterparties") - counterpartyOf Counterparty[] @relation("CounterpartyOf") - - // Сообщения - sentMessages Message[] @relation("SentMessages") - receivedMessages Message[] @relation("ReceivedMessages") - - // Услуги и расходники (только для фулфилмент центров) - services Service[] - supplies Supply[] - - // Товары (только для оптовиков) - products Product[] - - // Корзины - carts Cart[] - - // Избранные товары - favorites Favorites[] - + id String @id @default(cuid()) + inn String @unique + kpp String? + name String? + fullName String? + ogrn String? + ogrnDate DateTime? + type OrganizationType + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + address String? + addressFull String? + status String? + actualityDate DateTime? + registrationDate DateTime? + liquidationDate DateTime? + managementName String? + managementPost String? + opfCode String? + opfFull String? + opfShort String? + okato String? + oktmo String? + okpo String? + okved String? + phones Json? + emails Json? + employeeCount Int? + revenue BigInt? + taxSystem String? + dadataData Json? + apiKeys ApiKey[] + carts Cart? + counterpartyOf Counterparty[] @relation("CounterpartyOf") + organizationCounterparties Counterparty[] @relation("OrganizationCounterparties") + receivedRequests CounterpartyRequest[] @relation("ReceivedRequests") + sentRequests CounterpartyRequest[] @relation("SentRequests") + employees Employee[] + favorites Favorites[] + receivedMessages Message[] @relation("ReceivedMessages") + sentMessages Message[] @relation("SentMessages") + products Product[] + services Service[] + supplies Supply[] + users User[] + @@map("organizations") } -// Модель для API ключей маркетплейсов model ApiKey { - id String @id @default(cuid()) - marketplace MarketplaceType - apiKey String - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Данные для валидации (например, информация о продавце) + id String @id @default(cuid()) + marketplace MarketplaceType + apiKey String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt validationData Json? - - // Связь с организацией - organization Organization @relation(fields: [organizationId], references: [id]) organizationId String - + organization Organization @relation(fields: [organizationId], references: [id]) + @@unique([organizationId, marketplace]) @@map("api_keys") } -// Тип организации -enum OrganizationType { - FULFILLMENT // Фулфилмент - SELLER // Селлер - LOGIST // Логистика - WHOLESALE // Оптовик -} - -// Тип маркетплейса -enum MarketplaceType { - WILDBERRIES - OZON -} - -// Модель для заявок на добавление в контрагенты model CounterpartyRequest { - id String @id @default(cuid()) - status CounterpartyRequestStatus @default(PENDING) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Кто отправил заявку - sender Organization @relation("SentRequests", fields: [senderId], references: [id]) - senderId String - - // Кому отправили заявку - receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id]) - receiverId String - - // Комментарий к заявке - message String? - + id String @id @default(cuid()) + status CounterpartyRequestStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + senderId String + receiverId String + message String? + receiver Organization @relation("ReceivedRequests", fields: [receiverId], references: [id]) + sender Organization @relation("SentRequests", fields: [senderId], references: [id]) + @@unique([senderId, receiverId]) @@map("counterparty_requests") } -// Модель для связей контрагентов model Counterparty { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - - // Основная организация - organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id]) - organizationId String - - // Контрагент - counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id]) - counterpartyId String - + id String @id @default(cuid()) + createdAt DateTime @default(now()) + organizationId String + counterpartyId String + counterparty Organization @relation("CounterpartyOf", fields: [counterpartyId], references: [id]) + organization Organization @relation("OrganizationCounterparties", fields: [organizationId], references: [id]) + @@unique([organizationId, counterpartyId]) @@map("counterparties") } -// Статус заявки на добавление в контрагенты -enum CounterpartyRequestStatus { - PENDING // Ожидает ответа - ACCEPTED // Принята - REJECTED // Отклонена - CANCELLED // Отменена отправителем -} - -// Модель сообщений в мессенджере model Message { - id String @id @default(cuid()) - content String? // Текст сообщения (nullable для медиа) - type MessageType @default(TEXT) - voiceUrl String? // URL голосового файла в S3 - voiceDuration Int? // Длительность голосового сообщения в секундах - fileUrl String? // URL файла/изображения в S3 - fileName String? // Оригинальное имя файла - fileSize Int? // Размер файла в байтах - fileType String? // MIME тип файла - isRead Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Отправитель - sender User @relation("SentMessages", fields: [senderId], references: [id]) - senderId String - senderOrganization Organization @relation("SentMessages", fields: [senderOrganizationId], references: [id]) - senderOrganizationId String - - // Получатель - receiverOrganization Organization @relation("ReceivedMessages", fields: [receiverOrganizationId], references: [id]) + id String @id @default(cuid()) + content String? + type MessageType @default(TEXT) + voiceUrl String? + voiceDuration Int? + fileUrl String? + fileName String? + fileSize Int? + fileType String? + isRead Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + senderId String + senderOrganizationId 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([receiverOrganizationId, isRead]) @@map("messages") } -// Типы сообщений +model Service { + id String @id @default(cuid()) + name String + description String? + price Decimal @db.Decimal(10, 2) + imageUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@map("services") +} + +model Supply { + id String @id @default(cuid()) + name String + description String? + price Decimal @db.Decimal(10, 2) + quantity Int @default(0) + imageUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@map("supplies") +} + +model Category { + id String @id @default(cuid()) + name String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + products Product[] + + @@map("categories") +} + +model Product { + id String @id @default(cuid()) + name String + article String + description String? + price Decimal @db.Decimal(12, 2) + quantity Int @default(0) + categoryId String? + brand String? + color String? + size String? + weight Decimal? @db.Decimal(8, 3) + dimensions String? + material String? + images Json @default("[]") + mainImage String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organizationId String + cartItems CartItem[] + favorites Favorites[] + category Category? @relation(fields: [categoryId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@unique([organizationId, article]) + @@map("products") +} + +model Cart { + id String @id @default(cuid()) + organizationId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + items CartItem[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@map("carts") +} + +model CartItem { + id String @id @default(cuid()) + cartId String + productId String + quantity Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@unique([cartId, productId]) + @@map("cart_items") +} + +model Favorites { + id String @id @default(cuid()) + organizationId String + productId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@unique([organizationId, productId]) + @@map("favorites") +} + +model Employee { + id String @id @default(cuid()) + firstName String + lastName String + middleName String? + birthDate DateTime? + avatar String? + passportPhoto String? + passportSeries String? + passportNumber String? + passportIssued String? + passportDate DateTime? + address String? + position String + department String? + hireDate DateTime + salary Float? + status EmployeeStatus @default(ACTIVE) + phone String + email String? + telegram String? + whatsapp String? + emergencyContact String? + emergencyPhone String? + organizationId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + scheduleRecords EmployeeSchedule[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@map("employees") +} + +model EmployeeSchedule { + id String @id @default(cuid()) + date DateTime + status ScheduleStatus + hoursWorked Float? + notes String? + employeeId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + + @@unique([employeeId, date]) + @@map("employee_schedules") +} + +enum OrganizationType { + FULFILLMENT + SELLER + LOGIST + WHOLESALE +} + +enum MarketplaceType { + WILDBERRIES + OZON +} + +enum CounterpartyRequestStatus { + PENDING + ACCEPTED + REJECTED + CANCELLED +} + enum MessageType { TEXT VOICE @@ -249,164 +334,17 @@ enum MessageType { FILE } -// Модель услуг (для фулфилмент центров) -model Service { - id String @id @default(cuid()) - name String - description String? - price Decimal @db.Decimal(10,2) // Цена за единицу - imageUrl String? // URL фотографии в S3 - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Связь с организацией - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId String - - @@map("services") +enum EmployeeStatus { + ACTIVE + VACATION + SICK + FIRED } -// Модель расходников (для фулфилмент центров) -model Supply { - id String @id @default(cuid()) - name String - description String? - price Decimal @db.Decimal(10,2) // Цена за единицу - quantity Int @default(0) // Количество в наличии - imageUrl String? // URL фотографии в S3 - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Связь с организацией - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId String - - @@map("supplies") -} - -// Модель категорий товаров -model Category { - id String @id @default(cuid()) - name String @unique // Название категории - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Связь с товарами - products Product[] - - @@map("categories") -} - -// Модель товаров (для оптовиков) -model Product { - id String @id @default(cuid()) - - // Основные поля - name String // Название товара - article String // Артикул/номер записи - description String? // Описание - - // Цена и количество - price Decimal @db.Decimal(12,2) // Цена за единицу - quantity Int @default(0) // Количество в наличии - - // Основные характеристики - category Category? @relation(fields: [categoryId], references: [id]) - categoryId String? // ID категории - brand String? // Бренд - - // Дополнительные характеристики (необязательные) - color String? // Цвет - size String? // Размер - weight Decimal? @db.Decimal(8,3) // Вес в кг - dimensions String? // Габариты (ДxШxВ) - material String? // Материал - - // Изображения (JSON массив URL-ов в S3) - images Json @default("[]") // Массив URL изображений - mainImage String? // URL главного изображения - - // Статус товара - isActive Boolean @default(true) // Активен ли товар - - // Временные метки - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Связь с организацией (только оптовики) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId String - - // Связь с элементами корзины - cartItems CartItem[] - - // Избранные товары - favorites Favorites[] - - // Уникальность артикула в рамках организации - @@unique([organizationId, article]) - @@map("products") -} - -// Модель корзины -model Cart { - id String @id @default(cuid()) - - // Связь с организацией (только покупатель может иметь корзину) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId String @unique // У каждой организации может быть только одна корзина - - // Элементы корзины - items CartItem[] - - // Временные метки - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("carts") -} - -// Модель элемента корзины -model CartItem { - id String @id @default(cuid()) - - // Связь с корзиной - cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) - cartId String - - // Связь с товаром - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - productId String - - // Количество товара в корзине - quantity Int @default(1) - - // Временные метки - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Уникальность: один товар может быть только один раз в корзине - @@unique([cartId, productId]) - @@map("cart_items") -} - -// Модель избранных товаров -model Favorites { - id String @id @default(cuid()) - - // Связь с организацией (пользователь может добавлять товары в избранное) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId String - - // Связь с товаром - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - productId String - - // Временные метки - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Уникальность: один товар может быть только один раз в избранном у организации - @@unique([organizationId, productId]) - @@map("favorites") +enum ScheduleStatus { + WORK + WEEKEND + VACATION + SICK + ABSENT } diff --git a/src/app/api/upload-employee-document/route.ts b/src/app/api/upload-employee-document/route.ts new file mode 100644 index 0000000..48193d0 --- /dev/null +++ b/src/app/api/upload-employee-document/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/src/app/employees/page.tsx b/src/app/employees/page.tsx index 3d0154d..80bd10a 100644 --- a/src/app/employees/page.tsx +++ b/src/app/employees/page.tsx @@ -1,5 +1,5 @@ -import { AuthGuard } from "@/components/auth-guard" -import { EmployeesDashboard } from "@/components/employees/employees-dashboard" +import { AuthGuard } from '@/components/auth-guard' +import { EmployeesDashboard } from '@/components/employees/employees-dashboard' export default function EmployeesPage() { return ( diff --git a/src/components/employees/employee-edit-inline-form.tsx b/src/components/employees/employee-edit-inline-form.tsx new file mode 100644 index 0000000..170cb5d --- /dev/null +++ b/src/components/employees/employee-edit-inline-form.tsx @@ -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(null) + const passportInputRef = useRef(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 ( + + + + + Редактировать сотрудника: {employee.firstName} {employee.lastName} + + + + +
+ {/* Фотографии */} +
+ {/* Фото сотрудника */} +
+ + +
+ + {formData.avatar ? ( + { + console.error('Ошибка загрузки аватара:', formData.avatar); + e.currentTarget.style.display = 'none'; + }} + onLoad={() => console.log('Аватар загружен успешно:', formData.avatar)} + /> + ) : null} + + {getInitials() || } + + + +
+ e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')} + className="hidden" + /> + + + + {formData.avatar && ( + + )} +
+
+
+ + {/* Фото паспорта */} +
+ + +
+ {formData.passportPhoto ? ( +
+ Паспорт setShowPassportPreview(true)} + /> + +
+ Нажмите для увеличения +
+
+ ) : ( +
+
+ +

Паспорт не загружен

+

Рекомендуемый формат: JPG, PNG

+
+
+ )} + + e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')} + className="hidden" + /> + + +
+
+
+ + + + {/* Основная информация */} +
+ + +
+
+ + handleInputChange('firstName', e.target.value)} + placeholder="Александр" + className="glass-input text-white placeholder:text-white/40" + required + /> +
+ +
+ + handleInputChange('lastName', e.target.value)} + placeholder="Петров" + className="glass-input text-white placeholder:text-white/40" + required + /> +
+ +
+ + handleInputChange('middleName', e.target.value)} + placeholder="Иванович" + className="glass-input text-white placeholder:text-white/40" + /> +
+ +
+ + handleInputChange('birthDate', e.target.value)} + className="glass-input text-white" + /> +
+
+
+ + + + {/* Контактная информация */} +
+ + +
+
+ + { + 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 + /> +
+ +
+ + handleInputChange('telegram', e.target.value)} + placeholder="@username" + className="glass-input text-white placeholder:text-white/40" + /> +
+ +
+ + { + const formatted = formatPhoneInput(e.target.value) + handleInputChange('whatsapp', formatted) + }} + placeholder="+7 (999) 123-45-67" + className="glass-input text-white placeholder:text-white/40" + /> +
+ +
+ + handleInputChange('email', e.target.value)} + placeholder="a.petrov@company.com" + className="glass-input text-white placeholder:text-white/40" + /> +
+
+
+ + + + {/* Рабочая информация */} +
+ + +
+
+ + handleInputChange('position', e.target.value)} + placeholder="Менеджер склада" + className="glass-input text-white placeholder:text-white/40" + required + /> +
+ +
+ + handleInputChange('salary', parseInt(e.target.value) || 0)} + placeholder="80000" + className="glass-input text-white placeholder:text-white/40" + /> +
+
+
+ + {/* Кнопки управления */} +
+ + +
+ +
+ + {/* Превью паспорта */} + + + + Фото паспорта + +
+ Паспорт +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/employees/employee-form.tsx b/src/components/employees/employee-form.tsx new file mode 100644 index 0000000..7f7d467 --- /dev/null +++ b/src/components/employees/employee-form.tsx @@ -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) => 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(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 ( +
+ {/* Фото и основная информация */} + +

Личные данные

+ + {/* Аватар */} +
+
+ + {formData.avatar ? ( + + ) : null} + + {getInitials() || } + + + + e.target.files?.[0] && handleAvatarUpload(e.target.files[0])} + className="hidden" + /> + + +
+ +
+
+ + handleInputChange('firstName', e.target.value)} + placeholder="Александр" + className="glass-input text-white placeholder:text-white/40 h-10" + required + /> +
+ +
+ + handleInputChange('lastName', e.target.value)} + placeholder="Петров" + className="glass-input text-white placeholder:text-white/40 h-10" + required + /> +
+ +
+ + handleInputChange('middleName', e.target.value)} + placeholder="Иванович" + className="glass-input text-white placeholder:text-white/40 h-10" + /> +
+ +
+ + handleInputChange('birthDate', e.target.value)} + className="glass-input text-white h-10" + /> +
+ +
+ + handleInputChange('passportSeries', e.target.value)} + placeholder="1234" + className="glass-input text-white placeholder:text-white/40 h-10" + /> +
+ +
+ + handleInputChange('passportNumber', e.target.value)} + placeholder="567890" + className="glass-input text-white placeholder:text-white/40 h-10" + /> +
+ +
+ + handleInputChange('passportIssued', e.target.value)} + placeholder="ОУФМС России по г. Москве" + className="glass-input text-white placeholder:text-white/40 h-10" + /> +
+ +
+ + handleInputChange('passportDate', e.target.value)} + className="glass-input text-white h-10" + /> +
+
+
+ +
+ + handleInputChange('address', e.target.value)} + placeholder="Москва, ул. Ленина, 10, кв. 5" + className="glass-input text-white placeholder:text-white/40 h-10" + /> +
+
+ + {/* Рабочая информация */} + +

Трудовая деятельность

+
+
+ + handleInputChange('position', e.target.value)} + placeholder="Менеджер склада" + className="glass-input text-white placeholder:text-white/40 h-10" + required + /> +
+ +
+ + handleInputChange('hireDate', e.target.value)} + className="glass-input text-white h-10" + /> +
+ +
+ + +
+
+ +
+ + handleInputChange('salary', parseInt(e.target.value) || 0)} + placeholder="80000" + className="glass-input text-white placeholder:text-white/40 h-10" + /> +
+
+ + {/* Контактная информация */} + +

Контактные данные

+
+
+ + { + 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" + /> +
+ +
+ + handleInputChange('email', e.target.value)} + placeholder="a.petrov@company.com" + className="glass-input text-white placeholder:text-white/40 h-10" + /> +
+ +
+ + handleInputChange('emergencyContact', e.target.value)} + placeholder="ФИО близкого родственника" + className="glass-input text-white placeholder:text-white/40 h-10" + /> +
+ +
+ + { + 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" + /> +
+
+
+ + {/* Кнопки управления */} +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/employees/employee-inline-form.tsx b/src/components/employees/employee-inline-form.tsx new file mode 100644 index 0000000..5d6ca77 --- /dev/null +++ b/src/components/employees/employee-inline-form.tsx @@ -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(null) + const passportInputRef = useRef(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 ( + + + + + Добавить нового сотрудника + + + + +
+ {/* Фотографии */} +
+ {/* Фото сотрудника */} +
+ + +
+ + {formData.avatar ? ( + + ) : null} + + {getInitials() || } + + + +
+ e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')} + className="hidden" + /> + + + + {formData.avatar && ( + + )} +
+
+
+ + {/* Фото паспорта */} +
+ + +
+ {formData.passportPhoto ? ( +
+ Паспорт setShowPassportPreview(true)} + /> + +
+ Нажмите для увеличения +
+
+ ) : ( +
+
+ +

Паспорт не загружен

+

Рекомендуемый формат: JPG, PNG

+
+
+ )} + + e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')} + className="hidden" + /> + + +
+
+
+ + + + {/* Основная информация */} +
+ + +
+
+ + handleInputChange('firstName', e.target.value)} + placeholder="Александр" + className="glass-input text-white placeholder:text-white/40" + required + /> +
+ +
+ + handleInputChange('lastName', e.target.value)} + placeholder="Петров" + className="glass-input text-white placeholder:text-white/40" + required + /> +
+ +
+ + handleInputChange('middleName', e.target.value)} + placeholder="Иванович" + className="glass-input text-white placeholder:text-white/40" + /> +
+ +
+ + handleInputChange('birthDate', e.target.value)} + className="glass-input text-white" + /> +
+
+
+ + + + {/* Контактная информация */} +
+ + +
+
+ + { + 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 + /> +
+ +
+ + handleInputChange('telegram', e.target.value)} + placeholder="@username" + className="glass-input text-white placeholder:text-white/40" + /> +
+ +
+ + { + const formatted = formatPhoneInput(e.target.value) + handleInputChange('whatsapp', formatted) + }} + placeholder="+7 (999) 123-45-67" + className="glass-input text-white placeholder:text-white/40" + /> +
+ +
+ + handleInputChange('email', e.target.value)} + placeholder="a.petrov@company.com" + className="glass-input text-white placeholder:text-white/40" + /> +
+
+
+ + + + {/* Рабочая информация */} +
+ + +
+
+ + handleInputChange('position', e.target.value)} + placeholder="Менеджер склада" + className="glass-input text-white placeholder:text-white/40" + required + /> +
+ +
+ + handleInputChange('salary', parseInt(e.target.value) || 0)} + placeholder="80000" + className="glass-input text-white placeholder:text-white/40" + /> +
+
+
+ + {/* Кнопки управления */} +
+ + +
+ +
+ + {/* Превью паспорта */} + + + + Фото паспорта + +
+ Паспорт +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/employees/employee-schedule.tsx b/src/components/employees/employee-schedule.tsx index 407099f..102886f 100644 --- a/src/components/employees/employee-schedule.tsx +++ b/src/components/employees/employee-schedule.tsx @@ -35,15 +35,26 @@ interface EmployeeSchedule { days: WorkDay[] } -// Моковые данные сотрудников для календаря -const scheduleEmployees = [ - { id: '1', name: 'Александр Петров', avatar: undefined }, - { id: '2', name: 'Мария Иванова', avatar: undefined }, - { id: '3', name: 'Дмитрий Сидоров', avatar: undefined }, - { id: '4', name: 'Анна Козлова', avatar: undefined } -] +interface Employee { + id: string + firstName: string + lastName: string + position: string + department?: string + phone: string + email: string + avatar?: string + hireDate: string + status: 'active' | 'vacation' | 'sick' | 'inactive' + salary: number + address: string +} -export function EmployeeSchedule() { +interface EmployeeScheduleProps { + employees: Employee[] +} + +export function EmployeeSchedule({ employees }: EmployeeScheduleProps) { const [currentDate, setCurrentDate] = useState(new Date()) const [selectedEmployee, setSelectedEmployee] = useState('all') const [schedules, setSchedules] = useState>({}) @@ -295,9 +306,9 @@ export function EmployeeSchedule() { Все сотрудники - {scheduleEmployees.map(emp => ( + {employees.map(emp => ( - {emp.name} + {emp.firstName} {emp.lastName} ))} @@ -325,7 +336,7 @@ export function EmployeeSchedule() { {/* Календарь для каждого сотрудника */} - {(selectedEmployee === 'all' ? scheduleEmployees : scheduleEmployees.filter(e => e.id === selectedEmployee)).map(employee => { + {(selectedEmployee === 'all' ? employees : employees.filter(e => e.id === selectedEmployee)).map(employee => { const stats = getMonthStats(employee.id) return ( @@ -335,14 +346,14 @@ export function EmployeeSchedule() {
{employee.avatar ? ( - + ) : null} - {employee.name.split(' ').map(n => n.charAt(0)).join('')} + {employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
-

{employee.name}

+

{employee.firstName} {employee.lastName}

Табель работы за {monthNames[currentMonth].toLowerCase()}

diff --git a/src/components/employees/employees-dashboard.tsx b/src/components/employees/employees-dashboard.tsx index fcc0f5d..1cfc027 100644 --- a/src/components/employees/employees-dashboard.tsx +++ b/src/components/employees/employees-dashboard.tsx @@ -1,23 +1,241 @@ "use client" -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { useQuery, useMutation } from '@apollo/client' import { Sidebar } from '@/components/dashboard/sidebar' import { Card } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Input } from '@/components/ui/input' -import { EmployeesList } from './employees-list' -import { EmployeeSchedule } from './employee-schedule' +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { EmployeeForm } from './employee-form' +import { EmployeeInlineForm } from './employee-inline-form' +import { EmployeeEditInlineForm } from './employee-edit-inline-form' +import { toast } from 'sonner' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { GET_MY_EMPLOYEES } from '@/graphql/queries' +import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations' import { Users, Calendar, Search, Plus, - FileText + FileText, + Edit, + UserX, + Phone, + Mail, + Download, + BarChart3, + CheckCircle, + XCircle, + Plane, + Activity, + Clock } from 'lucide-react' +// Интерфейс сотрудника +interface Employee { + id: string + firstName: string + lastName: string + middleName?: string + position: string + phone: string + email?: string + avatar?: string + hireDate: string + status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED' + salary?: number + address?: string + birthDate?: string + passportSeries?: string + passportNumber?: string + passportIssued?: string + passportDate?: string + emergencyContact?: string + emergencyPhone?: string + telegram?: string + whatsapp?: string + passportPhoto?: string + createdAt: string + updatedAt: string +} + export function EmployeesDashboard() { const [searchQuery, setSearchQuery] = useState('') + const [showAddForm, setShowAddForm] = useState(false) + const [showEditForm, setShowEditForm] = useState(false) + const [createLoading, setCreateLoading] = useState(false) + const [editingEmployee, setEditingEmployee] = useState(null) + const [deletingEmployeeId, setDeletingEmployeeId] = useState(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) => { + 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) => { + 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 ( +
+ +
+
+
+
Загрузка сотрудников...
+
+
+
+
+ ) + } return (
@@ -33,10 +251,15 @@ export function EmployeesDashboard() {

Личные данные, табель работы и учет

- + + {/* Поиск */} @@ -52,22 +275,37 @@ export function EmployeesDashboard() { + {/* Форма добавления сотрудника */} + {showAddForm && ( + setShowAddForm(false)} + isLoading={createLoading} + /> + )} + + {/* Форма редактирования сотрудника */} + {showEditForm && editingEmployee && ( + { + setShowEditForm(false) + setEditingEmployee(null) + }} + isLoading={createLoading} + /> + )} + {/* Основной контент с вкладками */} - - + + - Список сотрудников - - - - Табель работы + Сотрудники и табель - - - + + + {(() => { + 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())) + ) - - + if (filteredEmployees.length === 0) { + return ( +
+
+
+ +
+

+ {searchQuery ? 'Сотрудники не найдены' : 'У вас пока нет сотрудников'} +

+

+ {searchQuery + ? 'Попробуйте изменить критерии поиска' + : 'Добавьте первого сотрудника в вашу команду' + } +

+ {!searchQuery && ( + + )} +
+
+ ) + } + + return ( +
+ {/* Навигация по месяцам */} +
+

Январь 2024

+
+ + + +
+
+ + {/* Легенда точно как в гите */} +
+
+
+ +
+ Рабочий день +
+
+
+ +
+ Выходной +
+
+
+ +
+ Отпуск +
+
+
+ +
+ Больничный +
+
+
+ +
+ Прогул +
+
+ + {/* Объединенный список сотрудников с табелем */} + {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 ( + +
+ {/* Информация о сотруднике */} +
+
+ + {employee.avatar ? ( + { + console.error('Ошибка загрузки аватара:', employee.avatar); + e.currentTarget.style.display = 'none'; + }} + onLoad={() => console.log('Аватар загружен успешно:', employee.avatar)} + /> + ) : null} + + {employee.firstName.charAt(0)}{employee.lastName.charAt(0)} + + + +
+
+

+ {employee.firstName} {employee.lastName} +

+
+ + + + + + + + + Уволить сотрудника? + + Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}? + Это действие нельзя отменить. + + + + + Отмена + + handleEmployeeDeleted(employee.id)} + disabled={deletingEmployeeId === employee.id} + className="bg-red-600 hover:bg-red-700 text-white" + > + {deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'} + + + + +
+
+ +

{employee.position}

+ {employee.department && ( +

{employee.department}

+ )} + +
+
+ + {employee.phone} +
+ {employee.email && ( +
+ + {employee.email} +
+ )} +
+
+
+ + {/* Статистика за месяц */} +
+
+

{stats.workDays}

+

Рабочих дней

+
+
+

{stats.vacationDays}

+

Отпуск

+
+
+

{stats.sickDays}

+

Больничный

+
+
+

{stats.totalHours}ч

+

Всего часов

+
+
+
+ + {/* Табель работы 1 в 1 как в гите */} +
+

+ + Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })} +

+ + {/* Сетка календаря - точно как в гите */} +
+ {/* Заголовки дней недели */} + {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => ( +
+ {day} +
+ ))} + + {/* Дни месяца */} + {(() => { + // Точная логика из гита + 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 + case 'weekend': + return + case 'vacation': + return + case 'sick': + return + case 'absent': + return + default: + return null + } + } + + return calendarDays.map((day, index) => { + if (day === null) { + return
+ } + + 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 ( +
{ + // Циклично переключаем статусы - точно как в гите + // const statuses = ['work', 'weekend', 'vacation', 'sick', 'absent'] + // const currentIndex = statuses.indexOf(status) + // const nextStatus = statuses[(currentIndex + 1) % statuses.length] + // changeDayStatus(employee.id, day, nextStatus) + }} + > +
+
+ {getStatusIcon(status)} + {day} +
+ {hours > 0 && ( + {hours}ч + )} +
+ + {isToday && ( +
+ )} +
+ ) + }) + })()} +
+
+
+
+ ) + })} +
+ ) + })()} +
- -
- -

Отчеты

-

- Генерация отчетов по работе сотрудников -

-

Функция в разработке

+ {employees.length === 0 ? ( + +
+
+
+ +
+

Нет данных для отчетов

+

+ Добавьте сотрудников, чтобы генерировать отчеты и аналитику +

+ +
+
+
+ ) : ( +
+ {/* Статистические карточки */} +
+ +
+
+

Всего сотрудников

+

{employees.length}

+
+ +
+
+ +
+
+

Активных

+

+ {employees.filter((e: Employee) => e.status === 'ACTIVE').length} +

+
+ +
+
+ +
+
+

Средняя зарплата

+

+ {employees.length > 0 + ? Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length).toLocaleString('ru-RU') + : '0'} ₽ +

+
+ +
+
+ +
+
+

Отделов

+

+ {new Set(employees.map((e: Employee) => e.department).filter(Boolean)).size} +

+
+ +
+
+
+ + {/* Экспорт отчетов */} + +

+ + Экспорт отчетов +

+ +
+
+

Детальный отчет (CSV)

+

+ Полная информация о всех сотрудниках в формате таблицы для Excel/Google Sheets +

+ +
+ +
+

Сводный отчет (TXT)

+

+ Краткая статистика и список сотрудников в текстовом формате +

+ +
+
+
+ + {/* Аналитика по отделам */} + +

Распределение по отделам

+
+ {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 ( +
+
+
+ {dept} + {deptEmployees.length} чел. ({percentage}%) +
+
+
+
+
+
+ ) + })} +
+
- + )}
diff --git a/src/components/employees/employees-list.tsx b/src/components/employees/employees-list.tsx index e835e80..65187ca 100644 --- a/src/components/employees/employees-list.tsx +++ b/src/components/employees/employees-list.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -18,7 +19,9 @@ import { User, Briefcase, Save, - X + X, + Trash2, + UserX } from 'lucide-react' // Интерфейс сотрудника @@ -27,7 +30,7 @@ interface Employee { firstName: string lastName: string position: string - department: string + department?: string phone: string email: string avatar?: string @@ -95,10 +98,12 @@ const mockEmployees: Employee[] = [ interface EmployeesListProps { searchQuery: string + employees: Employee[] + onEditEmployee: (employee: Employee) => void + onDeleteEmployee: (employeeId: string) => void } -export function EmployeesList({ searchQuery }: EmployeesListProps) { - const [employees, setEmployees] = useState(mockEmployees) +export function EmployeesList({ searchQuery, employees, onEditEmployee, onDeleteEmployee }: EmployeesListProps) { const [selectedEmployee, setSelectedEmployee] = useState(null) const [isEditModalOpen, setIsEditModalOpen] = useState(false) @@ -106,7 +111,7 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) { const filteredEmployees = employees.filter(employee => `${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) || employee.position.toLowerCase().includes(searchQuery.toLowerCase()) || - employee.department.toLowerCase().includes(searchQuery.toLowerCase()) + (employee.department && employee.department.toLowerCase().includes(searchQuery.toLowerCase())) ) const getStatusBadge = (status: Employee['status']) => { @@ -133,18 +138,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) { } const handleEditEmployee = (employee: Employee) => { - setSelectedEmployee(employee) - setIsEditModalOpen(true) + onEditEmployee(employee) } - const handleSaveEmployee = () => { - if (selectedEmployee) { - setEmployees(prev => - prev.map(emp => emp.id === selectedEmployee.id ? selectedEmployee : emp) - ) - setIsEditModalOpen(false) - setSelectedEmployee(null) - } + const handleDeleteEmployee = (employee: Employee) => { + onDeleteEmployee(employee.id) } return ( @@ -214,14 +212,48 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {

{employee.firstName} {employee.lastName}

- +
+ + + + + + + + + Уволить сотрудника? + + Вы уверены, что хотите уволить {employee.firstName} {employee.lastName}? + Это действие изменит статус сотрудника на "Неактивен" и удалит его из активного списка. + + + + + Отмена + + handleDeleteEmployee(employee)} + className="bg-red-600 hover:bg-red-700 text-white" + > + Уволить + + + + +

{employee.position}

@@ -389,11 +421,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {