Добавлены модели и функциональность для управления сотрудниками, включая создание, обновление и удаление сотрудников через GraphQL. Обновлены компоненты для отображения списка сотрудников и их расписания, улучшен интерфейс взаимодействия с пользователем. Реализованы функции экспорта отчетов в CSV и TXT форматах, добавлены новые интерфейсы и типы данных для сотрудников.
This commit is contained in:
453
src/components/employees/employee-form.tsx
Normal file
453
src/components/employees/employee-form.tsx
Normal file
@ -0,0 +1,453 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { Upload, X, User, Camera } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface EmployeeFormProps {
|
||||
employee?: Employee | null
|
||||
onSave: (employeeData: Partial<Employee>) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: employee?.firstName || '',
|
||||
lastName: employee?.lastName || '',
|
||||
middleName: employee?.middleName || '',
|
||||
position: employee?.position || '',
|
||||
phone: employee?.phone || '',
|
||||
email: employee?.email || '',
|
||||
avatar: employee?.avatar || '',
|
||||
hireDate: employee?.hireDate || new Date().toISOString().split('T')[0],
|
||||
status: employee?.status || 'ACTIVE' as const,
|
||||
salary: employee?.salary || 0,
|
||||
address: employee?.address || '',
|
||||
birthDate: employee?.birthDate || '',
|
||||
passportSeries: employee?.passportSeries || '',
|
||||
passportNumber: employee?.passportNumber || '',
|
||||
passportIssued: employee?.passportIssued || '',
|
||||
passportDate: employee?.passportDate || '',
|
||||
emergencyContact: employee?.emergencyContact || '',
|
||||
emergencyPhone: employee?.emergencyPhone || ''
|
||||
})
|
||||
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleInputChange = (field: string, value: string | number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (file: File) => {
|
||||
setIsUploadingAvatar(true)
|
||||
|
||||
try {
|
||||
const formDataUpload = new FormData()
|
||||
formDataUpload.append('file', file)
|
||||
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
|
||||
|
||||
const response = await fetch('/api/upload-avatar', {
|
||||
method: 'POST',
|
||||
body: formDataUpload
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки аватара')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
avatar: result.url
|
||||
}))
|
||||
|
||||
toast.success('Аватар успешно загружен')
|
||||
} catch (error) {
|
||||
console.error('Error uploading avatar:', error)
|
||||
toast.error('Ошибка при загрузке аватара')
|
||||
} finally {
|
||||
setIsUploadingAvatar(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
// Валидация
|
||||
if (!formData.firstName || !formData.lastName || !formData.position) {
|
||||
toast.error('Пожалуйста, заполните все обязательные поля')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
toast.error('Введите корректный email адрес')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.phone && !/^[\+]?[1-9][\d]{0,15}$/.test(formData.phone.replace(/\s/g, ''))) {
|
||||
toast.error('Введите корректный номер телефона')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Для создания/обновления отправляем только нужные поля
|
||||
const employeeData = {
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
middleName: formData.middleName,
|
||||
position: formData.position,
|
||||
phone: formData.phone,
|
||||
email: formData.email || undefined,
|
||||
avatar: formData.avatar || undefined,
|
||||
hireDate: formData.hireDate,
|
||||
salary: formData.salary || undefined,
|
||||
address: formData.address || undefined,
|
||||
birthDate: formData.birthDate || undefined,
|
||||
passportSeries: formData.passportSeries || undefined,
|
||||
passportNumber: formData.passportNumber || undefined,
|
||||
passportIssued: formData.passportIssued || undefined,
|
||||
passportDate: formData.passportDate || undefined,
|
||||
emergencyContact: formData.emergencyContact || undefined,
|
||||
emergencyPhone: formData.emergencyPhone || undefined
|
||||
}
|
||||
|
||||
onSave(employeeData)
|
||||
toast.success(employee ? 'Сотрудник успешно обновлен' : 'Сотрудник успешно добавлен')
|
||||
} catch (error) {
|
||||
console.error('Error saving employee:', error)
|
||||
toast.error('Ошибка при сохранении данных сотрудника')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getInitials = () => {
|
||||
const first = formData.firstName.charAt(0).toUpperCase()
|
||||
const last = formData.lastName.charAt(0).toUpperCase()
|
||||
return `${first}${last}`
|
||||
}
|
||||
|
||||
const formatPhoneInput = (value: string) => {
|
||||
const cleaned = value.replace(/\D/g, '')
|
||||
if (cleaned.length <= 1) return cleaned
|
||||
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
|
||||
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
|
||||
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
||||
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Фото и основная информация */}
|
||||
<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 ? (
|
||||
<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>
|
||||
|
||||
<div className="flex-1 grid 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.firstName}
|
||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||
placeholder="Александр"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
required
|
||||
/>
|
||||
</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 h-10"
|
||||
required
|
||||
/>
|
||||
</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 h-10"
|
||||
/>
|
||||
</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 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Серия паспорта</Label>
|
||||
<Input
|
||||
value={formData.passportSeries}
|
||||
onChange={(e) => handleInputChange('passportSeries', e.target.value)}
|
||||
placeholder="1234"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Номер паспорта</Label>
|
||||
<Input
|
||||
value={formData.passportNumber}
|
||||
onChange={(e) => handleInputChange('passportNumber', e.target.value)}
|
||||
placeholder="567890"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Кем выдан</Label>
|
||||
<Input
|
||||
value={formData.passportIssued}
|
||||
onChange={(e) => handleInputChange('passportIssued', e.target.value)}
|
||||
placeholder="ОУФМС России по г. Москве"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Дата выдачи</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.passportDate}
|
||||
onChange={(e) => handleInputChange('passportDate', e.target.value)}
|
||||
className="glass-input text-white h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Label className="text-white/80 text-sm mb-2 block">Адрес проживания</Label>
|
||||
<Input
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
placeholder="Москва, ул. Ленина, 10, кв. 5"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Рабочая информация */}
|
||||
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-4">Трудовая деятельность</h3>
|
||||
<div className="grid 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 h-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Дата приема на работу</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => handleInputChange('hireDate', e.target.value)}
|
||||
className="glass-input text-white h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Статус</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value: 'active' | 'vacation' | 'sick' | 'inactive') => handleInputChange('status', value)}
|
||||
>
|
||||
<SelectTrigger className="glass-input text-white h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-900 border-white/20">
|
||||
<SelectItem value="active" className="text-white hover:bg-white/10">Активен</SelectItem>
|
||||
<SelectItem value="vacation" className="text-white hover:bg-white/10">В отпуске</SelectItem>
|
||||
<SelectItem value="sick" className="text-white hover:bg-white/10">На больничном</SelectItem>
|
||||
<SelectItem value="inactive" className="text-white hover:bg-white/10">Неактивен</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Label className="text-white/80 text-sm mb-2 block">Зарплата (₽)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.salary || ''}
|
||||
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
|
||||
placeholder="80000"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Контактная информация */}
|
||||
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-4">Контактные данные</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Телефон</Label>
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={(e) => {
|
||||
const formatted = formatPhoneInput(e.target.value)
|
||||
handleInputChange('phone', formatted)
|
||||
}}
|
||||
placeholder="+7 (999) 123-45-67"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">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 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Экстренный контакт</Label>
|
||||
<Input
|
||||
value={formData.emergencyContact}
|
||||
onChange={(e) => handleInputChange('emergencyContact', e.target.value)}
|
||||
placeholder="ФИО близкого родственника"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/80 text-sm mb-2 block">Телефон экстренного контакта</Label>
|
||||
<Input
|
||||
value={formData.emergencyPhone}
|
||||
onChange={(e) => {
|
||||
const formatted = formatPhoneInput(e.target.value)
|
||||
handleInputChange('emergencyPhone', formatted)
|
||||
}}
|
||||
placeholder="+7 (999) 123-45-67"
|
||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Кнопки управления */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || isUploadingAvatar}
|
||||
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 ? 'Сохранить изменения' : 'Добавить сотрудника')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user