Добавлена возможность загрузки фото паспорта сотрудников через API. Реализована валидация загружаемых файлов, включая проверку типа и размера. Обновлены компоненты формы для отображения и загрузки аватара и паспорта. Оптимизирована логика обработки ошибок при загрузке.у
This commit is contained in:
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
797
src/components/employees/employees-dashboard.tsx.backup
Normal file
797
src/components/employees/employees-dashboard.tsx.backup
Normal 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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user