Files
sfera-new/src/components/employees/employee-compact-form.tsx
Bivekich 9062891b0a feat: Comprehensive employee management system improvements
-  Added compact employee forms (add/edit) with all fields visible
- 🎯 Implemented expandable employee rows with timesheet integration
- 📊 Added real KPI calculation based on work hours, sick days, and overtime
- 📅 Added bulk date selection and editing in calendar
- 🗓️ Implemented day-specific editing modal with hours and overtime tracking
- 💾 Extended database schema with overtimeHours field
- 🎨 Improved UI layout: tabs left, search right, real current date display
- 🧹 Fixed spacing issues and removed unnecessary gaps
- 🔧 Enhanced GraphQL mutations for employee schedule management
2025-07-30 17:33:37 +03:00

542 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import {
User,
UserPlus,
Phone,
Mail,
Briefcase,
DollarSign,
AlertCircle,
Save,
X,
Camera,
Calendar,
MessageCircle,
FileImage,
RefreshCw
} from 'lucide-react'
import { toast } from 'sonner'
import {
formatPhoneInput,
formatSalary,
formatNameInput,
isValidEmail,
isValidPhone,
isValidSalary,
isValidBirthDate
} from '@/lib/input-masks'
interface EmployeeCompactFormProps {
onSave: (employeeData: {
firstName: string
lastName: string
middleName?: string
phone: string
email?: string
position: string
salary?: number
avatar?: string
birthDate?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
hireDate: string
}) => void
onCancel: () => void
isLoading?: boolean
}
interface ValidationErrors {
[key: string]: string
}
export function EmployeeCompactForm({ onSave, onCancel, isLoading = false }: EmployeeCompactFormProps) {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
middleName: '',
phone: '',
email: '',
position: '',
salary: 0,
avatar: '',
birthDate: '',
telegram: '',
whatsapp: '',
passportPhoto: ''
})
const [errors, setErrors] = useState<ValidationErrors>({})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(null)
const validateField = (field: string, value: string | number): string | null => {
switch (field) {
case 'firstName':
case 'lastName':
if (!value || String(value).trim() === '') {
return field === 'firstName' ? 'Имя обязательно' : 'Фамилия обязательна'
}
if (String(value).length < 2) {
return field === 'firstName' ? 'Имя минимум 2 символа' : 'Фамилия минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return field === 'firstName' ? 'Только буквы, пробелы и дефисы' : 'Только буквы, пробелы и дефисы'
}
break
case 'middleName':
if (value && String(value).length > 0) {
if (String(value).length < 2) {
return 'Отчество минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return 'Только буквы, пробелы и дефисы'
}
}
break
case 'position':
if (!value || String(value).trim() === '') {
return 'Должность обязательна'
}
if (String(value).length < 2) {
return 'Должность минимум 2 символа'
}
break
case 'phone':
case 'whatsapp':
if (field === 'phone' && (!value || String(value).trim() === '')) {
return 'Телефон обязателен'
}
if (value && String(value).trim() !== '' && !isValidPhone(String(value))) {
return 'Некорректный формат телефона'
}
break
case 'email':
if (value && String(value).trim() !== '' && !isValidEmail(String(value))) {
return 'Некорректный email'
}
break
case 'birthDate':
if (value && String(value).trim() !== '') {
const validation = isValidBirthDate(String(value))
if (!validation.valid) {
return validation.message || 'Некорректная дата рождения'
}
}
break
case 'salary':
const salaryValidation = isValidSalary(Number(value))
if (!salaryValidation.valid) {
return salaryValidation.message || 'Некорректная зарплата'
}
break
}
return null
}
const handleInputChange = (field: string, value: string | number) => {
let processedValue = value
// Применяем маски ввода
if (typeof value === 'string') {
switch (field) {
case 'phone':
case 'whatsapp':
processedValue = formatPhoneInput(value)
break
case 'firstName':
case 'lastName':
case 'middleName':
processedValue = formatNameInput(value)
break
}
}
setFormData(prev => ({
...prev,
[field]: processedValue
}))
// Валидация в реальном времени
const error = validateField(field, processedValue)
setErrors(prev => ({
...prev,
[field]: error || ''
}))
}
const handleSalaryChange = (value: string) => {
const numericValue = parseInt(value.replace(/\D/g, '')) || 0
setFormData(prev => ({
...prev,
salary: numericValue
}))
const error = validateField('salary', numericValue)
setErrors(prev => ({
...prev,
salary: error || ''
}))
}
const handleFileUpload = async (file: File, type: 'avatar' | 'passport') => {
const setLoading = type === 'avatar' ? setIsUploadingAvatar : setIsUploadingPassport
setLoading(true)
try {
const formDataUpload = new FormData()
formDataUpload.append('file', file)
let endpoint: string
if (type === 'avatar') {
formDataUpload.append('userId', `temp_${Date.now()}`)
endpoint = '/api/upload-avatar'
} else {
// Для фото паспорта используем специальный endpoint для документов
formDataUpload.append('documentType', 'passport-photo')
endpoint = '/api/upload-employee-document'
}
const response = await fetch(endpoint, {
method: 'POST',
body: formDataUpload
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Ошибка загрузки ${type === 'avatar' ? 'аватара' : 'паспорта'}`)
}
const result = await response.json()
setFormData(prev => ({
...prev,
[type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url
}))
toast.success(`${type === 'avatar' ? 'Аватар' : 'Фото паспорта'} успешно загружен`)
} catch (error) {
console.error(`Error uploading ${type}:`, error)
const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'аватара' : 'паспорта'}`
toast.error(errorMessage)
} finally {
setLoading(false)
}
}
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {}
// Валидируем все поля
Object.keys(formData).forEach(field => {
const error = validateField(field, formData[field as keyof typeof formData])
if (error) {
newErrors[field] = error
}
})
setErrors(newErrors)
return Object.keys(newErrors).filter(key => newErrors[key]).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
toast.error('Исправьте ошибки в форме')
return
}
// Подготавливаем данные для отправки
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName || undefined,
phone: formData.phone,
email: formData.email || undefined,
position: formData.position,
salary: formData.salary || undefined,
avatar: formData.avatar || undefined,
birthDate: formData.birthDate || undefined,
telegram: formData.telegram || undefined,
whatsapp: formData.whatsapp || undefined,
passportPhoto: formData.passportPhoto || undefined,
hireDate: new Date().toISOString().split('T')[0]
}
onSave(employeeData)
}
// Компонент для отображения ошибок
const ErrorMessage = ({ error }: { error: string }) => {
if (!error) return null
return (
<div className="flex items-center gap-1 mt-1 text-red-400 text-xs">
<AlertCircle className="h-3 w-3 flex-shrink-0" />
<span>{error}</span>
</div>
)
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
return `${first}${last}`
}
return (
<Card className="glass-card p-4 mb-6">
<form onSubmit={handleSubmit}>
<div className="space-y-4">
{/* Заголовок */}
<div className="flex items-center gap-2 mb-4">
<UserPlus className="h-5 w-5 text-purple-400" />
<h3 className="text-white font-semibold">Быстрое добавление сотрудника</h3>
<span className="text-white/60 text-sm ml-2">(табель работы будет доступен после создания)</span>
</div>
<div className="flex items-center gap-4">
{/* Аватар с возможностью загрузки */}
<div className="flex-shrink-0">
<div className="relative">
<Avatar className="h-12 w-12 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">
{getInitials() || <User className="h-6 w-6" />}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="avatar-upload-compact" 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>
</div>
{/* Основные поля в одну строку */}
<div className="flex-1 grid grid-cols-6 gap-3 items-start">
{/* Имя */}
<div className="relative">
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Имя *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.firstName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.firstName} />
</div>
{/* Фамилия */}
<div className="relative">
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Фамилия *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.lastName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.lastName} />
</div>
{/* Должность */}
<div className="relative">
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Должность *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.position ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.position} />
</div>
{/* Телефон */}
<div className="relative">
<Input
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="Телефон *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.phone ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.phone} />
</div>
{/* Email */}
<div className="relative">
<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-10 text-sm ${errors.email ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.email} />
</div>
{/* Зарплата */}
<div className="relative">
<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-10 text-sm ${errors.salary ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.salary} />
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-2 flex-shrink-0">
<Button
type="button"
size="sm"
variant="ghost"
onClick={onCancel}
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-10 w-10 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
size="sm"
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 h-10 px-4"
>
{isLoading ? (
'Создание...'
) : (
<>
<Save className="h-4 w-4 mr-2" />
Добавить
</>
)}
</Button>
</div>
</div>
{/* Дополнительные поля - всегда видимы */}
<div className="mt-4 p-4 bg-white/5 rounded-lg border border-white/10">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<User className="h-4 w-4" />
Дополнительная информация
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Дата рождения */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Дата рождения</label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className={`glass-input text-white h-9 text-sm ${errors.birthDate ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.birthDate} />
</div>
{/* Telegram */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Telegram</label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.telegram ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.telegram} />
</div>
{/* WhatsApp */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">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 h-9 text-sm ${errors.whatsapp ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.whatsapp} />
</div>
{/* Фото паспорта */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Фото паспорта</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<div className="w-full h-9 rounded-lg border border-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"
/>
) : (
<span className="text-white/40 text-xs">Не загружено</span>
)}
</div>
</div>
<label htmlFor="passport-upload-compact" className="cursor-pointer">
<div className="w-9 h-9 bg-blue-600 rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors">
{isUploadingPassport ? (
<RefreshCw className="h-4 w-4 text-white animate-spin" />
) : (
<Camera className="h-4 w-4 text-white" />
)}
</div>
</label>
</div>
</div>
</div>
</div>
{/* Скрытые input для загрузки файлов */}
<input
id="avatar-upload-compact"
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
disabled={isUploadingAvatar}
/>
<input
id="passport-upload-compact"
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
disabled={isUploadingPassport}
/>
</div>
</form>
</Card>
)
}