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

This commit is contained in:
Bivekich
2025-07-30 15:32:21 +03:00
parent 28312830a4
commit c99104c5ce
4 changed files with 1394 additions and 347 deletions

View File

@ -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 }
)
}
}

View File

@ -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<ValidationErrors>({})
const fileInputRef = useRef<HTMLInputElement>(null)
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(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)
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Личные данные</h3>
{/* Аватар */}
{/* Аватар и фото паспорта */}
<div className="flex items-start gap-6 mb-6">
<div className="flex flex-col items-center gap-3">
<Avatar className="h-24 w-24 ring-2 ring-white/20">
{formData.avatar && formData.avatar.trim() !== '' ? (
<AvatarImage src={formData.avatar} alt="Аватар сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleAvatarUpload(e.target.files[0])}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Изменить фото'}
</Button>
{/* Блок с аватаром и фото паспорта вертикально */}
<div className="flex flex-col items-center gap-4">
{/* Аватар */}
<div className="flex flex-col items-center gap-3">
<div className="relative">
<Avatar className="h-24 w-24 ring-2 ring-white/20">
{formData.avatar && formData.avatar.trim() !== '' ? (
<AvatarImage src={formData.avatar} alt="Аватар сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="avatar-upload" className="cursor-pointer">
<div className="w-6 h-6 bg-purple-600 rounded-full flex items-center justify-center hover:bg-purple-700 transition-colors">
{isUploadingAvatar ? (
<RefreshCw className="h-3 w-3 text-white animate-spin" />
) : (
<Camera className="h-3 w-3 text-white" />
)}
</div>
</label>
<input
id="avatar-upload"
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleAvatarUpload(e.target.files[0])}
className="hidden"
disabled={isUploadingAvatar}
/>
</div>
</div>
<span className="text-white/60 text-xs text-center">Аватар</span>
</div>
{/* Фото паспорта */}
<div className="flex flex-col items-center gap-3">
<div className="relative">
<div className="w-24 h-24 rounded-lg ring-2 ring-white/20 bg-white/5 flex items-center justify-center overflow-hidden">
{formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
<img
src={formData.passportPhoto}
alt="Фото паспорта"
className="w-full h-full object-cover"
/>
) : (
<FileImage className="h-8 w-8 text-white/40" />
)}
</div>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="passport-upload" className="cursor-pointer">
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center hover:bg-blue-700 transition-colors">
{isUploadingPassport ? (
<RefreshCw className="h-3 w-3 text-white animate-spin" />
) : (
<Camera className="h-3 w-3 text-white" />
)}
</div>
</label>
<input
id="passport-upload"
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handlePassportPhotoUpload(e.target.files[0])}
className="hidden"
disabled={isUploadingPassport}
/>
</div>
</div>
<span className="text-white/60 text-xs text-center">Фото паспорта</span>
</div>
</div>
<div className="flex-1 grid grid-cols-2 gap-4">
@ -614,10 +696,10 @@ export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps)
</Button>
<Button
type="submit"
disabled={loading || isUploadingAvatar}
disabled={loading || isUploadingAvatar || isUploadingPassport}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
{loading ? 'Сохранение...' : (employee ? 'Сохранить изменения' : 'Добавить сотрудника')}
{loading ? 'Сохранение...' : (isUploadingAvatar || isUploadingPassport) ? 'Загрузка файлов...' : (employee ? 'Сохранить изменения' : 'Добавить сотрудника')}
</Button>
</div>
</form>

View File

@ -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 (
<Card className="glass-card mb-6">
<CardHeader className="pb-4">
<CardTitle className="text-white text-xl flex items-center gap-3">
<UserPlus className="h-6 w-6 text-purple-400" />
Добавить нового сотрудника
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Фотографии */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Фото сотрудника */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<Camera className="h-4 w-4" />
Фото сотрудника
</Label>
<>
<Card className="glass-card p-6 mb-6">
<form onSubmit={handleSubmit}>
<div className="flex flex-col lg:flex-row gap-6">
{/* Информация о сотруднике - точно как в карточке */}
<div className="lg:w-80 flex-shrink-0">
<div className="flex items-start space-x-4 mb-4">
{/* Блок с аватаром и фото паспорта вертикально */}
<div className="flex flex-col items-center gap-4">
{/* Аватар с иконкой камеры */}
<div className="flex flex-col items-center gap-2">
<div className="relative">
<Avatar className="h-16 w-16 ring-2 ring-white/20">
{formData.avatar && formData.avatar.trim() !== '' ? (
<AvatarImage src={formData.avatar} alt="Фото сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold text-lg">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="avatar-upload-inline" className="cursor-pointer">
<div className="w-5 h-5 bg-purple-600 rounded-full flex items-center justify-center hover:bg-purple-700 transition-colors">
{isUploadingAvatar ? (
<RefreshCw className="h-2.5 w-2.5 text-white animate-spin" />
) : (
<Camera className="h-2.5 w-2.5 text-white" />
)}
</div>
</label>
</div>
</div>
<span className="text-white/60 text-xs text-center">Аватар</span>
</div>
{/* Фото паспорта */}
<div className="flex flex-col items-center gap-2">
<div className="relative">
<div className="w-16 h-16 rounded-lg ring-2 ring-white/20 bg-white/5 flex items-center justify-center overflow-hidden">
{formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
<img
src={formData.passportPhoto}
alt="Фото паспорта"
className="w-full h-full object-cover cursor-pointer"
onClick={() => setShowPassportPreview(true)}
/>
) : (
<FileImage className="h-6 w-6 text-white/40" />
)}
</div>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="passport-upload-inline" className="cursor-pointer">
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center hover:bg-blue-700 transition-colors">
{isUploadingPassport ? (
<RefreshCw className="h-2.5 w-2.5 text-white animate-spin" />
) : (
<Camera className="h-2.5 w-2.5 text-white" />
)}
</div>
</label>
</div>
</div>
<span className="text-white/60 text-xs text-center">Паспорт</span>
</div>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20 ring-2 ring-white/20">
{formData.avatar && formData.avatar.trim() !== '' ? (
<AvatarImage src={formData.avatar} alt="Фото сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-semibold text-lg">
<UserPlus className="h-5 w-5 text-purple-400 inline mr-2" />
Новый сотрудник
</h3>
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={onCancel}
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<div className="mb-4">
<div className="space-y-3">
{/* Имя */}
<div className="flex items-center text-white/70">
<User className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Имя *"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.firstName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.firstName} />
</div>
</div>
{/* Фамилия */}
<div className="flex items-center text-white/70">
<User className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Фамилия *"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.lastName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.lastName} />
</div>
</div>
{/* Отчество */}
<div className="flex items-center text-white/70">
<User className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Отчество"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.middleName ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.middleName} />
</div>
</div>
{/* Должность */}
<div className="flex items-center text-white/70">
<Briefcase className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Должность *"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.position ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.position} />
</div>
</div>
{/* Телефон */}
<div className="flex items-center text-white/70">
<Phone className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="Телефон *"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.phone ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.phone} />
</div>
</div>
{/* Email */}
<div className="flex items-center text-white/70">
<Mail className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="Email"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.email ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.email} />
</div>
</div>
{/* Дата рождения */}
<div className="flex items-center text-white/70">
<Calendar className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
placeholder="Дата рождения"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.birthDate ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.birthDate} />
</div>
</div>
{/* Зарплата */}
<div className="flex items-center text-white/70">
<DollarSign className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.salary ? formatSalary(formData.salary.toString()) : ''}
onChange={(e) => handleSalaryChange(e.target.value)}
placeholder="Зарплата"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.salary ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.salary} />
</div>
</div>
{/* Telegram */}
<div className="flex items-center text-white/70">
<MessageCircle className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@telegram"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.telegram ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.telegram} />
</div>
</div>
{/* WhatsApp */}
<div className="flex items-center text-white/70">
<Phone className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.whatsapp}
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
placeholder="WhatsApp"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.whatsapp ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.whatsapp} />
</div>
</div>
</div>
</div>
<div className="space-y-2 text-sm">
{/* Скрытые input элементы для загрузки файлов */}
<input
id="avatar-upload-inline"
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
disabled={isUploadingAvatar}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => avatarInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Загрузить фото'}
</Button>
{formData.avatar && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, avatar: '' }))}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-2" />
Удалить
</Button>
)}
<input
id="passport-upload-inline"
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
disabled={isUploadingPassport}
/>
</div>
</div>
</div>
</div>
{/* Фото паспорта */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Фото паспорта
</Label>
{/* Табель работы - точно как в карточке но пустой */}
<div className="flex-1 space-y-4">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Табель работы (будет доступен после создания)
</h4>
{/* Пустая сетка календаря */}
<div className="grid grid-cols-7 gap-2 opacity-50">
{/* Заголовки дней недели */}
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
{day}
</div>
))}
<div className="space-y-3">
{formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
<div className="relative">
<Image
src={formData.passportPhoto}
alt="Паспорт"
width={400}
height={300}
className="w-full h-auto max-h-48 object-contain rounded-lg border border-white/20 bg-white/5 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPassportPreview(true)}
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, passportPhoto: '' }))}
className="absolute top-2 right-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
Нажмите для увеличения
</div>
</div>
) : (
<div className="h-48 border-2 border-dashed border-white/20 rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="h-8 w-8 text-white/40 mx-auto mb-2" />
<p className="text-white/60 text-sm">Паспорт не загружен</p>
<p className="text-white/40 text-xs mt-1">Рекомендуемый формат: JPG, PNG</p>
{/* Пустые дни месяца */}
{Array.from({ length: 35 }, (_, i) => {
const day = i + 1
if (day > 31) return <div key={i} className="p-2"></div>
return (
<div
key={i}
className="relative p-2 min-h-[60px] border rounded-lg bg-white/5 border-white/10"
>
<div className="flex flex-col items-center justify-center h-full">
<span className="font-semibold text-sm text-white/40">{day <= 31 ? day : ''}</span>
</div>
</div>
)
})}
</div>
{/* Статистика - пустая */}
<div className="grid grid-cols-4 gap-3 mt-4 opacity-50">
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-white/40 font-semibold text-lg">0</p>
<p className="text-white/40 text-xs">Рабочих дней</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-white/40 font-semibold text-lg">0</p>
<p className="text-white/40 text-xs">Отпуск</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-white/40 font-semibold text-lg">0</p>
<p className="text-white/40 text-xs">Больничный</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-white/40 font-semibold text-lg">0ч</p>
<p className="text-white/40 text-xs">Всего часов</p>
</div>
</div>
{/* Кнопка сохранения */}
<div className="flex justify-end pt-4">
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
size="lg"
>
{isLoading ? 'Создание сотрудника...' : (
<>
<Save className="h-4 w-4 mr-2" />
Создать сотрудника
</>
)}
<input
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => passportInputRef.current?.click()}
disabled={isUploadingPassport}
className="w-full glass-secondary text-white hover:text-white"
>
<FileText className="h-4 w-4 mr-2" />
{isUploadingPassport ? 'Загрузка...' : 'Загрузить паспорт'}
</Button>
</div>
</Button>
</div>
</div>
<Separator className="bg-white/10" />
{/* Основная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<User className="h-4 w-4" />
Личные данные
</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Имя <span className="text-red-400">*</span>
</Label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className={`glass-input text-white placeholder:text-white/40 ${errors.firstName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.firstName} />
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Фамилия <span className="text-red-400">*</span>
</Label>
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className={`glass-input text-white placeholder:text-white/40 ${errors.lastName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.lastName} />
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Отчество</Label>
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className={`glass-input text-white placeholder:text-white/40 ${errors.middleName ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.middleName} />
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Дата рождения
</Label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="glass-input text-white"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Контактная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Phone className="h-4 w-4" />
Контактная информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Телефон <span className="text-red-400">*</span>
</Label>
<Input
value={formData.phone}
onChange={(e) => 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
/>
<ErrorMessage error={errors.phone} />
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
Telegram
</Label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
WhatsApp
</Label>
<Input
value={formData.whatsapp}
onChange={(e) => 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' : ''}`}
/>
<ErrorMessage error={errors.whatsapp} />
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<Mail className="h-3 w-3" />
Email
</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className={`glass-input text-white placeholder:text-white/40 ${errors.email ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.email} />
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Рабочая информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Рабочая информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Должность <span className="text-red-400">*</span>
</Label>
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className={`glass-input text-white placeholder:text-white/40 ${errors.position ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.position} />
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<DollarSign className="h-3 w-3" />
Зарплата
</Label>
<Input
value={formData.salary ? formatSalary(formData.salary.toString()) : ''}
onChange={(e) => handleSalaryChange(e.target.value)}
placeholder="80 000"
className={`glass-input text-white placeholder:text-white/40 ${errors.salary ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.salary} />
</div>
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300"
>
<X className="h-4 w-4 mr-2" />
Отмена
</Button>
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
{isLoading ? 'Сохранение...' : (
<>
<Save className="h-4 w-4 mr-2" />
Добавить сотрудника
</>
)}
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
{/* Превью паспорта */}
<Dialog open={showPassportPreview} onOpenChange={setShowPassportPreview}>
@ -664,6 +682,6 @@ export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: Empl
</div>
</DialogContent>
</Dialog>
</Card>
</>
)
}

View File

@ -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<Employee | null>(null)
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
const [currentYear] = useState(new Date().getFullYear())
const [currentMonth] = useState(new Date().getMonth())
const [expandedEmployees, setExpandedEmployees] = useState<Set<string>>(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<Employee>) => {
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<Employee>) => {
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 (
<div className="min-h-screen flex">
<Sidebar />
<main className="flex-1 ml-56 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center h-64">
<div className="text-white text-xl">Загрузка сотрудников...</div>
</div>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen flex">
<Sidebar />
<main className="flex-1 ml-56 p-6">
<div className="max-w-7xl mx-auto">
{/* Заголовок страницы */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Users className="h-8 w-8 text-purple-400" />
<div>
<h1 className="text-2xl font-bold text-white">Управление сотрудниками</h1>
<p className="text-white/70">Личные данные, табель работы и учет</p>
</div>
</div>
<Button
onClick={() => setShowAddForm(!showAddForm)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
{showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
</Button>
</div>
{/* Поиск */}
<Card className="glass-card p-4 mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/60 h-4 w-4" />
<Input
placeholder="Поиск сотрудников..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="glass-input pl-10"
/>
</div>
</Card>
{/* Форма добавления сотрудника */}
{showAddForm && (
<EmployeeInlineForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
)}
{/* Форма редактирования сотрудника */}
{showEditForm && editingEmployee && (
<EmployeeEditInlineForm
employee={editingEmployee}
onSave={handleEmployeeSaved}
onCancel={() => {
setShowEditForm(false)
setEditingEmployee(null)
}}
isLoading={createLoading}
/>
)}
{/* Основной контент с вкладками */}
<Tabs defaultValue="combined" className="w-full">
<TabsList className="glass-card mb-6 grid w-full grid-cols-2">
<TabsTrigger
value="combined"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Users className="h-4 w-4 mr-2" />
Сотрудники и табель
</TabsTrigger>
<TabsTrigger
value="reports"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<FileText className="h-4 w-4 mr-2" />
Отчеты
</TabsTrigger>
</TabsList>
<TabsContent value="combined">
<Card className="glass-card p-6">
{(() => {
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 (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="h-8 w-8 text-white/40" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
{searchQuery ? 'Сотрудники не найдены' : 'У вас пока нет сотрудников'}
</h3>
<p className="text-white/60 text-sm mb-4">
{searchQuery
? 'Попробуйте изменить критерии поиска'
: 'Добавьте первого сотрудника в вашу команду'
}
</p>
{!searchQuery && (
<Button
onClick={() => setShowAddForm(true)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
Добавить сотрудника
</Button>
)}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Легенда статусов */}
<div className="flex flex-wrap items-center gap-6 p-4 bg-white/5 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80 flex items-center justify-center">
<CheckCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Рабочий день</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-purple-500/20 text-purple-300/70 border-purple-400/80 flex items-center justify-center">
<Clock className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Выходной</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-blue-500/20 text-blue-300/70 border-blue-400/80 flex items-center justify-center">
<Plane className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Отпуск</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80 flex items-center justify-center">
<Activity className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Больничный</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-red-500/20 text-red-300/70 border-red-400/80 flex items-center justify-center">
<XCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Прогул</span>
</div>
</div>
{/* Строчный список сотрудников с сворачиваемым табелем */}
<div className="space-y-3">
{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 (
<div key={employee.id} className="glass-card border border-white/10 rounded-lg overflow-hidden">
{/* Строчка сотрудника */}
<div className="flex items-center justify-between p-4 hover:bg-white/5 transition-colors">
{/* Левая часть - аватар и основная информация */}
<div className="flex items-center gap-4 flex-1">
<Avatar className="h-12 w-12 ring-2 ring-white/20">
{employee.avatar ? (
<AvatarImage src={employee.avatar} alt={`${employee.firstName} ${employee.lastName}`} />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</AvatarFallback>
</Avatar>
{/* Основная информация */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-white font-semibold truncate">
{employee.firstName} {employee.lastName}
</h3>
<span className="text-purple-300 text-sm">{employee.position}</span>
</div>
<div className="flex items-center gap-4 text-sm text-white/70">
<span className="flex items-center gap-1">
<Phone className="h-3 w-3" />
{employee.phone}
</span>
{employee.email && (
<span className="flex items-center gap-1">
<Mail className="h-3 w-3" />
{employee.email}
</span>
)}
<span className="flex items-center gap-1">
<Briefcase className="h-3 w-3" />
Принят: {new Date(employee.hireDate).toLocaleDateString('ru-RU')}
</span>
</div>
</div>
</div>
{/* Правая часть - кнопки управления */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => handleEditEmployee(employee)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => toggleEmployeeExpansion(employee.id)}
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<UserX className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="glass-card border-white/10">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
<AlertDialogDescription className="text-white/70">
Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}?
Это действие нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleEmployeeDeleted(employee.id)}
disabled={deletingEmployeeId === employee.id}
className="bg-red-600 hover:bg-red-700 text-white"
>
{deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* Сворачиваемый блок с табелем */}
{isExpanded && (
<div className="border-t border-white/10 p-4 bg-white/5">
<div className="mb-4">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })}
</h4>
{/* Интерактивная календарная сетка в стиле UI Kit */}
<div className="grid grid-cols-7 gap-2">
{/* Заголовки дней недели */}
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
{day}
</div>
))}
{/* Дни месяца с интерактивным стилем */}
{(() => {
const calendarDays: (number | null)[] = []
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay()
const startOffset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1
// Добавляем пустые ячейки для выравнивания первой недели
for (let i = 0; i < startOffset; i++) {
calendarDays.push(null)
}
// Добавляем дни месяца
for (let day = 1; day <= daysInMonth; day++) {
calendarDays.push(day)
}
return calendarDays.map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="p-2"></div>
}
const status = generateDayStatus(day)
const isToday = new Date().getDate() === day &&
new Date().getMonth() === currentMonth &&
new Date().getFullYear() === currentYear
return (
<div
key={`${employee.id}-${day}`}
className={`
relative p-2 min-h-[50px] border rounded-lg cursor-pointer
transition-all duration-300 hover:scale-105 active:scale-95
${status === 'work'
? 'bg-gradient-to-br from-emerald-400/30 to-green-400/30 border-emerald-400/50 hover:border-emerald-300/70 shadow-lg shadow-emerald-500/20'
: status === 'weekend'
? 'bg-gradient-to-br from-slate-400/30 to-gray-400/30 border-slate-400/50 hover:border-slate-300/70 shadow-lg shadow-slate-500/20'
: status === 'vacation'
? 'bg-gradient-to-br from-blue-400/30 to-cyan-400/30 border-blue-400/50 hover:border-blue-300/70 shadow-lg shadow-blue-500/20'
: status === 'sick'
? 'bg-gradient-to-br from-amber-400/30 to-orange-400/30 border-amber-400/50 hover:border-amber-300/70 shadow-lg shadow-amber-500/20'
: 'bg-gradient-to-br from-red-400/30 to-rose-400/30 border-red-400/50 hover:border-red-300/70 shadow-lg shadow-red-500/20'
}
${isToday ? 'ring-2 ring-white/50' : ''}
`}
onClick={() => changeDayStatus(employee.id, day, status)}
>
<div className="flex flex-col items-center justify-center h-full">
<div className="flex items-center gap-1 mb-1">
{status === 'work' && <CheckCircle className="h-3 w-3" />}
{status === 'weekend' && <Clock className="h-3 w-3" />}
{status === 'vacation' && <Plane className="h-3 w-3" />}
{status === 'sick' && <Activity className="h-3 w-3" />}
{status === 'absent' && <XCircle className="h-3 w-3" />}
<span className="font-semibold text-sm text-white">{day}</span>
</div>
{status === 'work' && (
<span className="text-xs opacity-80">8ч</span>
)}
</div>
{isToday && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
)}
</div>
)
})
})()}
</div>
{/* Статистика за месяц */}
<div className="grid grid-cols-4 gap-3 mt-4">
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-emerald-200 font-semibold text-lg">{stats.workDays}</p>
<p className="text-white/60 text-xs">Рабочих дней</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-sky-200 font-semibold text-lg">{stats.vacationDays}</p>
<p className="text-white/60 text-xs">Отпуск</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-amber-200 font-semibold text-lg">{stats.sickDays}</p>
<p className="text-white/60 text-xs">Больничный</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-white font-semibold text-lg">{stats.totalHours}ч</p>
<p className="text-white/60 text-xs">Всего часов</p>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
})()}
</Card>
</TabsContent>
<TabsContent value="reports">
{employees.length === 0 ? (
<Card className="glass-card p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<BarChart3 className="h-8 w-8 text-white/40" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Нет данных для отчетов</h3>
<p className="text-white/60 text-sm mb-4">
Добавьте сотрудников, чтобы генерировать отчеты и аналитику
</p>
<Button
onClick={() => setShowAddForm(true)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
Добавить сотрудника
</Button>
</div>
</div>
</Card>
) : (
<div className="space-y-6">
{/* Статистические карточки */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Всего сотрудников</p>
<p className="text-2xl font-bold text-white">{employees.length}</p>
</div>
<Users className="h-8 w-8 text-purple-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Активных</p>
<p className="text-2xl font-bold text-green-400">
{employees.filter((e: Employee) => e.status === 'ACTIVE').length}
</p>
</div>
<BarChart3 className="h-8 w-8 text-green-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">В отпуске</p>
<p className="text-2xl font-bold text-blue-400">
{employees.filter((e: Employee) => e.status === 'VACATION').length}
</p>
</div>
<Plane className="h-8 w-8 text-blue-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">На больничном</p>
<p className="text-2xl font-bold text-yellow-400">
{employees.filter((e: Employee) => e.status === 'SICK').length}
</p>
</div>
<Activity className="h-8 w-8 text-yellow-400" />
</div>
</Card>
</div>
{/* Кнопка генерации отчета */}
<Card className="glass-card p-6">
<div className="text-center">
<h3 className="text-lg font-medium text-white mb-2">Сводный отчет</h3>
<p className="text-white/60 text-sm mb-4">
Экспортируйте данные всех сотрудников в CSV файл
</p>
<Button
onClick={generateReport}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white border-0 shadow-lg shadow-green-500/25 transition-all duration-300"
>
<Download className="h-4 w-4 mr-2" />
Скачать отчет CSV
</Button>
</div>
</Card>
</div>
)}
</TabsContent>
</Tabs>
</div>
</main>
</div>
)
}