From c99104c5ce8be10eaa4e56115c6cca662bfb6c97 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Wed, 30 Jul 2025 15:32:21 +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=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=84=D0=BE=D1=82=D0=BE=20=D0=BF=D0=B0=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B0=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20API.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B6=D0=B0=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D1=83=20=D1=82=D0=B8=D0=BF=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=D0=B0.=20=D0=9E?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D1=80=D0=B0=20=D0=B8=20=D0=BF=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B0.=20=D0=9E=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B5.=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/upload-employee-document/route.ts | 152 +++- src/components/employees/employee-form.tsx | 154 +++- .../employees/employee-inline-form.tsx | 638 +++++++------- .../employees/employees-dashboard.tsx.backup | 797 ++++++++++++++++++ 4 files changed, 1394 insertions(+), 347 deletions(-) create mode 100644 src/components/employees/employees-dashboard.tsx.backup diff --git a/src/app/api/upload-employee-document/route.ts b/src/app/api/upload-employee-document/route.ts index 3371120..e207c23 100644 --- a/src/app/api/upload-employee-document/route.ts +++ b/src/app/api/upload-employee-document/route.ts @@ -1,5 +1,155 @@ 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_DOCUMENT_TYPES = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', + 'application/pdf' +] export async function POST(request: NextRequest) { - return NextResponse.json({ message: 'Upload employee document API' }) + try { + const formData = await request.formData() + const file = formData.get('file') as File + const documentType = formData.get('documentType') as string + + 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_DOCUMENT_TYPES.includes(file.type)) { + return NextResponse.json( + { error: `File type ${file.type} is not allowed. Only images and PDFs are supported.` }, + { status: 400 } + ) + } + + // Ограничиваем размер файла (5MB для документов) + if (file.size > 5 * 1024 * 1024) { + return NextResponse.json( + { error: 'File size must be less than 5MB' }, + { status: 400 } + ) + } + + // Генерируем уникальное имя файла + const timestamp = Date.now() + const fileExtension = file.name.split('.').pop()?.toLowerCase() + const fileName = `${documentType}-${timestamp}.${fileExtension}` + const key = `employee-documents/${fileName}` + + // Конвертируем файл в Buffer + const buffer = Buffer.from(await file.arrayBuffer()) + + // Очищаем метаданные от недопустимых символов + const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_') + const cleanDocumentType = documentType.replace(/[^\w-]/g, '') + + // Загружаем в S3 + const command = new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: buffer, + ContentType: file.type, + ACL: 'public-read', + Metadata: { + originalname: cleanOriginalName, + documenttype: cleanDocumentType, + type: 'employee-document' + } + }) + + 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) + return NextResponse.json( + { error: 'Failed to upload document' }, + { status: 500 } + ) + } +} + +export async function DELETE(request: NextRequest) { + try { + const { key } = await request.json() + + if (!key) { + return NextResponse.json( + { error: 'Key is required' }, + { status: 400 } + ) + } + + // TODO: Добавить удаление из S3 + // const command = new DeleteObjectCommand({ + // Bucket: BUCKET_NAME, + // Key: key + // }) + // await s3Client.send(command) + + return NextResponse.json({ success: true }) + + } catch (error) { + console.error('Error deleting employee document:', error) + return NextResponse.json( + { error: 'Failed to delete document' }, + { status: 500 } + ) + } } \ No newline at end of file diff --git a/src/components/employees/employee-form.tsx b/src/components/employees/employee-form.tsx index a77fc7b..1164d75 100644 --- a/src/components/employees/employee-form.tsx +++ b/src/components/employees/employee-form.tsx @@ -7,7 +7,7 @@ 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 { User, Camera, AlertCircle } from 'lucide-react' +import { User, Camera, AlertCircle, RefreshCw, FileImage } from 'lucide-react' import { toast } from 'sonner' import { formatPhoneInput, @@ -80,13 +80,16 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) passportIssued: employee?.passportIssued || '', passportDate: employee?.passportDate || '', emergencyContact: employee?.emergencyContact || '', - emergencyPhone: employee?.emergencyPhone || '' + emergencyPhone: employee?.emergencyPhone || '', + passportPhoto: employee?.passportPhoto || '' }) const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) + const [isUploadingPassport, setIsUploadingPassport] = useState(false) const [loading, setLoading] = useState(false) const [errors, setErrors] = useState({}) - const fileInputRef = useRef(null) + const avatarInputRef = useRef(null) + const passportInputRef = useRef(null) const validateField = (field: string, value: string | number): string | null => { switch (field) { @@ -268,6 +271,39 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) } } + const handlePassportPhotoUpload = async (file: File) => { + setIsUploadingPassport(true) + + try { + const formDataUpload = new FormData() + formDataUpload.append('file', file) + formDataUpload.append('documentType', 'passport-photo') + + const response = await fetch('/api/upload-employee-document', { + method: 'POST', + body: formDataUpload + }) + + if (!response.ok) { + throw new Error('Ошибка загрузки фото паспорта') + } + + const result = await response.json() + + setFormData(prev => ({ + ...prev, + passportPhoto: result.url + })) + + toast.success('Фото паспорта успешно загружено') + } catch (error) { + console.error('Error uploading passport photo:', error) + toast.error('Ошибка при загрузке фото паспорта') + } finally { + setIsUploadingPassport(false) + } + } + const validateForm = (): boolean => { const newErrors: ValidationErrors = {} @@ -311,7 +347,8 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) passportIssued: formData.passportIssued || undefined, passportDate: formData.passportDate || undefined, emergencyContact: formData.emergencyContact || undefined, - emergencyPhone: formData.emergencyPhone || undefined + emergencyPhone: formData.emergencyPhone || undefined, + passportPhoto: formData.passportPhoto || undefined } onSave(employeeData) @@ -347,37 +384,82 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)

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

- {/* Аватар */} + {/* Аватар и фото паспорта */}
-
- - {formData.avatar && formData.avatar.trim() !== '' ? ( - - ) : null} - - {getInitials() || } - - - - e.target.files?.[0] && handleAvatarUpload(e.target.files[0])} - className="hidden" - /> - - + {/* Блок с аватаром и фото паспорта вертикально */} +
+ {/* Аватар */} +
+
+ + {formData.avatar && formData.avatar.trim() !== '' ? ( + + ) : null} + + {getInitials() || } + + +
+ + e.target.files?.[0] && handleAvatarUpload(e.target.files[0])} + className="hidden" + disabled={isUploadingAvatar} + /> +
+
+ Аватар +
+ + {/* Фото паспорта */} +
+
+
+ {formData.passportPhoto && formData.passportPhoto.trim() !== '' ? ( + Фото паспорта + ) : ( + + )} +
+
+ + e.target.files?.[0] && handlePassportPhotoUpload(e.target.files[0])} + className="hidden" + disabled={isUploadingPassport} + /> +
+
+ Фото паспорта +
@@ -614,10 +696,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
diff --git a/src/components/employees/employee-inline-form.tsx b/src/components/employees/employee-inline-form.tsx index d2c3b83..05dac23 100644 --- a/src/components/employees/employee-inline-form.tsx +++ b/src/components/employees/employee-inline-form.tsx @@ -22,7 +22,10 @@ import { DollarSign, FileText, MessageCircle, - AlertCircle + AlertCircle, + Calendar, + RefreshCw, + FileImage } from 'lucide-react' import { toast } from 'sonner' import { @@ -214,8 +217,8 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl let endpoint: string if (type === 'avatar') { - // Для аватара используем upload-avatar API - formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`) + // Для аватара используем upload-avatar API и добавляем временный userId + formDataUpload.append('userId', `temp_${Date.now()}`) endpoint = '/api/upload-avatar' } else { // Для паспорта используем специальный API для документов сотрудников @@ -268,6 +271,12 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl }) setErrors(newErrors) + + // Дебаг: показываем все ошибки в консоли + if (Object.keys(newErrors).filter(key => newErrors[key]).length > 0) { + console.log('Ошибки валидации:', newErrors) + } + return Object.keys(newErrors).filter(key => newErrors[key]).length === 0 } @@ -317,333 +326,342 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl } return ( - - - - - Добавить нового сотрудника - - - - -
- {/* Фотографии */} -
- {/* Фото сотрудника */} -
- + <> + + +
+ {/* Информация о сотруднике - точно как в карточке */} +
+
+ {/* Блок с аватаром и фото паспорта вертикально */} +
+ {/* Аватар с иконкой камеры */} +
+
+ + {formData.avatar && formData.avatar.trim() !== '' ? ( + + ) : null} + + {getInitials() || } + + +
+ +
+
+ Аватар +
+ + {/* Фото паспорта */} +
+
+
+ {formData.passportPhoto && formData.passportPhoto.trim() !== '' ? ( + Фото паспорта setShowPassportPreview(true)} + /> + ) : ( + + )} +
+
+ +
+
+ Паспорт +
+
-
- - {formData.avatar && formData.avatar.trim() !== '' ? ( - - ) : null} - - {getInitials() || } - - +
+
+

+ + Новый сотрудник +

+
+ +
+
-
+
+
+ {/* Имя */} +
+ +
+ handleInputChange('firstName', e.target.value)} + placeholder="Имя *" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.firstName ? 'border-red-400' : ''}`} + required + /> + +
+
+ + {/* Фамилия */} +
+ +
+ handleInputChange('lastName', e.target.value)} + placeholder="Фамилия *" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.lastName ? 'border-red-400' : ''}`} + required + /> + +
+
+ + {/* Отчество */} +
+ +
+ handleInputChange('middleName', e.target.value)} + placeholder="Отчество" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.middleName ? 'border-red-400' : ''}`} + /> + +
+
+ + {/* Должность */} +
+ +
+ handleInputChange('position', e.target.value)} + placeholder="Должность *" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.position ? 'border-red-400' : ''}`} + required + /> + +
+
+ + {/* Телефон */} +
+ +
+ handleInputChange('phone', e.target.value)} + placeholder="Телефон *" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.phone ? 'border-red-400' : ''}`} + required + /> + +
+
+ + {/* Email */} +
+ +
+ handleInputChange('email', e.target.value)} + placeholder="Email" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.email ? 'border-red-400' : ''}`} + /> + +
+
+ + {/* Дата рождения */} +
+ +
+ handleInputChange('birthDate', e.target.value)} + placeholder="Дата рождения" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.birthDate ? 'border-red-400' : ''}`} + /> + +
+
+ + {/* Зарплата */} +
+ +
+ handleSalaryChange(e.target.value)} + placeholder="Зарплата" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.salary ? 'border-red-400' : ''}`} + /> + +
+
+ + {/* Telegram */} +
+ +
+ handleInputChange('telegram', e.target.value)} + placeholder="@telegram" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.telegram ? 'border-red-400' : ''}`} + /> + +
+
+ + {/* WhatsApp */} +
+ +
+ handleInputChange('whatsapp', e.target.value)} + placeholder="WhatsApp" + className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.whatsapp ? 'border-red-400' : ''}`} + /> + +
+
+
+
+ +
+ + {/* Скрытые input элементы для загрузки файлов */} e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')} className="hidden" + disabled={isUploadingAvatar} /> - - - {formData.avatar && ( - - )} + e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')} + className="hidden" + disabled={isUploadingPassport} + />
+
- {/* Фото паспорта */} -
- + {/* Табель работы - точно как в карточке но пустой */} +
+

+ + Табель работы (будет доступен после создания) +

+ + {/* Пустая сетка календаря */} +
+ {/* Заголовки дней недели */} + {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => ( +
+ {day} +
+ ))} -
- {formData.passportPhoto && formData.passportPhoto.trim() !== '' ? ( -
- Паспорт setShowPassportPreview(true)} - /> - -
- Нажмите для увеличения -
-
- ) : ( -
-
- -

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

-

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

+ {/* Пустые дни месяца */} + {Array.from({ length: 35 }, (_, i) => { + const day = i + 1 + if (day > 31) return
+ + return ( +
+
+ {day <= 31 ? day : ''}
+ ) + })} +
+ + {/* Статистика - пустая */} +
+
+

0

+

Рабочих дней

+
+
+

0

+

Отпуск

+
+
+

0

+

Больничный

+
+
+

+

Всего часов

+
+
+ + {/* Кнопка сохранения */} +
+ -
+
- - - - {/* Основная информация */} -
- - -
-
- - handleInputChange('firstName', e.target.value)} - placeholder="Александр" - className={`glass-input text-white placeholder:text-white/40 ${errors.firstName ? 'border-red-400' : ''}`} - required - /> - -
- -
- - handleInputChange('lastName', e.target.value)} - placeholder="Петров" - className={`glass-input text-white placeholder:text-white/40 ${errors.lastName ? 'border-red-400' : ''}`} - required - /> - -
- -
- - handleInputChange('middleName', e.target.value)} - placeholder="Иванович" - className={`glass-input text-white placeholder:text-white/40 ${errors.middleName ? 'border-red-400' : ''}`} - /> - -
- -
- - handleInputChange('birthDate', e.target.value)} - className="glass-input text-white" - /> -
-
-
- - - - {/* Контактная информация */} -
- - -
-
- - handleInputChange('phone', e.target.value)} - placeholder="+7 (999) 123-45-67" - className={`glass-input text-white placeholder:text-white/40 ${errors.phone ? 'border-red-400' : ''}`} - required - /> - -
- -
- - handleInputChange('telegram', e.target.value)} - placeholder="@username" - className="glass-input text-white placeholder:text-white/40" - /> -
- -
- - handleInputChange('whatsapp', e.target.value)} - placeholder="+7 (999) 123-45-67" - className={`glass-input text-white placeholder:text-white/40 ${errors.whatsapp ? 'border-red-400' : ''}`} - /> - -
- -
- - handleInputChange('email', e.target.value)} - placeholder="a.petrov@company.com" - className={`glass-input text-white placeholder:text-white/40 ${errors.email ? 'border-red-400' : ''}`} - /> - -
-
-
- - - - {/* Рабочая информация */} -
- - -
-
- - handleInputChange('position', e.target.value)} - placeholder="Менеджер склада" - className={`glass-input text-white placeholder:text-white/40 ${errors.position ? 'border-red-400' : ''}`} - required - /> - -
- -
- - handleSalaryChange(e.target.value)} - placeholder="80 000" - className={`glass-input text-white placeholder:text-white/40 ${errors.salary ? 'border-red-400' : ''}`} - /> - -
-
-
- - {/* Кнопки управления */} -
- - -
+
- + {/* Превью паспорта */} @@ -664,6 +682,6 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
- + ) } \ No newline at end of file diff --git a/src/components/employees/employees-dashboard.tsx.backup b/src/components/employees/employees-dashboard.tsx.backup new file mode 100644 index 0000000..38d8869 --- /dev/null +++ b/src/components/employees/employees-dashboard.tsx.backup @@ -0,0 +1,797 @@ +"use client" + +import { useState, useEffect, useMemo } from 'react' +import { useQuery, useMutation } from '@apollo/client' +import { apolloClient } from '@/lib/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 { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' +// 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, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries' +import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations' +import { + Users, + Calendar, + Search, + Plus, + FileText, + Edit, + UserX, + Phone, + Mail, + Download, + BarChart3, + CheckCircle, + XCircle, + Plane, + Activity, + Clock, + Briefcase, + MapPin, + AlertCircle, + MessageCircle, + ChevronDown, + ChevronUp +} 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) + const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({}) + const [currentYear] = useState(new Date().getFullYear()) + const [currentMonth] = useState(new Date().getMonth()) + const [expandedEmployees, setExpandedEmployees] = useState>(new Set()) + + interface ScheduleRecord { + id: string + date: string + status: string + hoursWorked?: number + employee: { + id: string + } + } + + // 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 [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE) + + const employees = useMemo(() => data?.myEmployees || [], [data?.myEmployees]) + + // Загружаем данные табеля для всех сотрудников + useEffect(() => { + const loadScheduleData = async () => { + if (employees.length > 0) { + const schedulePromises = employees.map(async (employee: Employee) => { + try { + const { data } = await apolloClient.query({ + query: GET_EMPLOYEE_SCHEDULE, + variables: { + employeeId: employee.id, + year: currentYear, + month: currentMonth + } + }) + return { employeeId: employee.id, scheduleData: data?.employeeSchedule || [] } + } catch (error) { + console.error(`Error loading schedule for ${employee.id}:`, error) + return { employeeId: employee.id, scheduleData: [] } + } + }) + + const results = await Promise.all(schedulePromises) + const scheduleMap: {[key: string]: ScheduleRecord[]} = {} + results.forEach((result: { employeeId: string; scheduleData: ScheduleRecord[] }) => { + if (result && result.scheduleData) { + scheduleMap[result.employeeId] = result.scheduleData + } + }) + setEmployeeSchedules(scheduleMap) + } + } + + loadScheduleData() + }, [employees, currentYear, currentMonth]) + + const handleEditEmployee = (employee: Employee) => { + setEditingEmployee(employee) + setShowEditForm(true) + setShowAddForm(false) // Закрываем форму добавления если открыта + } + + const toggleEmployeeExpansion = (employeeId: string) => { + setExpandedEmployees(prev => { + const newSet = new Set(prev) + if (newSet.has(employeeId)) { + newSet.delete(employeeId) + } else { + newSet.add(employeeId) + } + return newSet + }) + } + + const handleEmployeeSaved = async (employeeData: Partial) => { + try { + const { data: updatedData } = await updateEmployee({ + variables: { + id: editingEmployee!.id, + input: employeeData + } + }) + + if (updatedData?.updateEmployee) { + toast.success('Сотрудник успешно обновлен') + await refetch() + setShowEditForm(false) + setEditingEmployee(null) + } + } catch (error) { + console.error('Error updating employee:', error) + toast.error('Ошибка при обновлении сотрудника') + } + } + + const handleCreateEmployee = async (employeeData: Partial) => { + setCreateLoading(true) + try { + const { data: createdData } = await createEmployee({ + variables: { + input: employeeData + } + }) + + if (createdData?.createEmployee) { + toast.success('Сотрудник успешно добавлен') + await refetch() + setShowAddForm(false) + } + } catch (error) { + console.error('Error creating employee:', error) + toast.error('Ошибка при создании сотрудника') + } + setCreateLoading(false) + } + + const handleEmployeeDeleted = async (employeeId: string) => { + setDeletingEmployeeId(employeeId) + try { + await deleteEmployee({ + variables: { id: employeeId } + }) + + toast.success('Сотрудник уволен') + await refetch() + } catch (error) { + console.error('Error deleting employee:', error) + toast.error('Ошибка при увольнении сотрудника') + } + setDeletingEmployeeId(null) + } + + // Функция для изменения статуса дня в табеле + const changeDayStatus = (employeeId: string, day: number, currentStatus: string) => { + // Циклично переключаем статусы + const statusCycle = ['work', 'weekend', 'vacation', 'sick', 'absent'] + const currentIndex = statusCycle.indexOf(currentStatus) + const nextStatus = statusCycle[(currentIndex + 1) % statusCycle.length] + + // TODO: Реализовать сохранение в базу данных через GraphQL мутацию + console.log(`Changing status for employee ${employeeId}, day ${day} from ${currentStatus} to ${nextStatus}`) + } + + // Функция для генерации отчета + const generateReport = () => { + const reportData = employees.map((employee: Employee) => ({ + name: `${employee.firstName} ${employee.lastName}`, + position: employee.position, + phone: employee.phone, + email: employee.email || 'Не указан', + hireDate: new Date(employee.hireDate).toLocaleDateString('ru-RU'), + status: employee.status === 'ACTIVE' ? 'Активен' : + employee.status === 'VACATION' ? 'В отпуске' : + employee.status === 'SICK' ? 'На больничном' : 'Уволен' + })) + + // Создаем CSV контент + const csvHeaders = ['Имя', 'Должность', 'Телефон', 'Email', 'Дата приема', 'Статус'] + const csvRows = reportData.map(emp => [ + emp.name, + emp.position, + emp.phone, + emp.email, + emp.hireDate, + emp.status + ]) + + const csvContent = [ + csvHeaders.join(','), + ...csvRows.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`) + link.style.visibility = 'hidden' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + toast.success('Сводный отчет создан') + } + + if (loading) { + return ( +
+ +
+
+
+
Загрузка сотрудников...
+
+
+
+
+ ) + } + + return ( +
+ +
+
+ {/* Заголовок страницы */} +
+
+ +
+

Управление сотрудниками

+

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

+
+
+ + + +
+ + {/* Поиск */} + +
+ + setSearchQuery(e.target.value)} + className="glass-input pl-10" + /> +
+
+ + {/* Форма добавления сотрудника */} + {showAddForm && ( + setShowAddForm(false)} + isLoading={createLoading} + /> + )} + + {/* Форма редактирования сотрудника */} + {showEditForm && editingEmployee && ( + { + setShowEditForm(false) + setEditingEmployee(null) + }} + isLoading={createLoading} + /> + )} + + {/* Основной контент с вкладками */} + + + + + Сотрудники и табель + + + + + Отчеты + + + + + + {(() => { + const filteredEmployees = employees.filter((employee: Employee) => + `${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) || + employee.position.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + if (filteredEmployees.length === 0) { + return ( +
+
+
+ +
+

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

+

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

+ {!searchQuery && ( + + )} +
+
+ ) + } + + return ( +
+ {/* Легенда статусов */} +
+
+
+ +
+ Рабочий день +
+
+
+ +
+ Выходной +
+
+
+ +
+ Отпуск +
+
+
+ +
+ Больничный +
+
+
+ +
+ Прогул +
+
+ + {/* Строчный список сотрудников с сворачиваемым табелем */} +
+ {filteredEmployees.map((employee: Employee) => { + const isExpanded = expandedEmployees.has(employee.id) + // Генерируем календарные дни для текущего месяца + 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 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 ? ( + + ) : null} + + {employee.firstName.charAt(0)}{employee.lastName.charAt(0)} + + + + {/* Основная информация */} +
+
+

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

+ {employee.position} +
+
+ + + {employee.phone} + + {employee.email && ( + + + {employee.email} + + )} + + + Принят: {new Date(employee.hireDate).toLocaleDateString('ru-RU')} + +
+
+
+ + {/* Правая часть - кнопки управления */} +
+ + + + + + + + + + + Уволить сотрудника? + + Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}? + Это действие нельзя отменить. + + + + + Отмена + + handleEmployeeDeleted(employee.id)} + disabled={deletingEmployeeId === employee.id} + className="bg-red-600 hover:bg-red-700 text-white" + > + {deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'} + + + + +
+
+ + {/* Сворачиваемый блок с табелем */} + {isExpanded && ( +
+
+

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

+ + {/* Интерактивная календарная сетка в стиле UI Kit */} +
+ {/* Заголовки дней недели */} + {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].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) + } + + return calendarDays.map((day, index) => { + if (day === null) { + return
+ } + + const status = generateDayStatus(day) + const isToday = new Date().getDate() === day && + new Date().getMonth() === currentMonth && + new Date().getFullYear() === currentYear + + return ( +
changeDayStatus(employee.id, day, status)} + > +
+
+ {status === 'work' && } + {status === 'weekend' && } + {status === 'vacation' && } + {status === 'sick' && } + {status === 'absent' && } + {day} +
+ {status === 'work' && ( + + )} +
+ + {isToday && ( +
+ )} +
+ ) + }) + })()} +
+ + {/* Статистика за месяц */} +
+
+

{stats.workDays}

+

Рабочих дней

+
+
+

{stats.vacationDays}

+

Отпуск

+
+
+

{stats.sickDays}

+

Больничный

+
+
+

{stats.totalHours}ч

+

Всего часов

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

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

+

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

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

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

+

{employees.length}

+
+ +
+
+ +
+
+

Активных

+

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

+
+ +
+
+ +
+
+

В отпуске

+

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

+
+ +
+
+ +
+
+

На больничном

+

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

+
+ +
+
+
+ + {/* Кнопка генерации отчета */} + +
+

Сводный отчет

+

+ Экспортируйте данные всех сотрудников в CSV файл +

+ +
+
+
+ )} +
+ +
+
+
+ ) +} \ No newline at end of file