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
This commit is contained in:
Bivekich
2025-07-30 17:33:37 +03:00
parent 84720a634d
commit 9062891b0a
12 changed files with 2449 additions and 528 deletions

View File

@ -0,0 +1,542 @@
"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>
)
}