Добавлены модели и функциональность для управления сотрудниками, включая создание, обновление и удаление сотрудников через GraphQL. Обновлены компоненты для отображения списка сотрудников и их расписания, улучшен интерфейс взаимодействия с пользователем. Реализованы функции экспорта отчетов в CSV и TXT форматах, добавлены новые интерфейсы и типы данных для сотрудников.
This commit is contained in:
521
src/components/employees/employee-inline-form.tsx
Normal file
521
src/components/employees/employee-inline-form.tsx
Normal file
@ -0,0 +1,521 @@
|
||||
"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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Camera,
|
||||
User,
|
||||
X,
|
||||
Save,
|
||||
UserPlus,
|
||||
Phone,
|
||||
Mail,
|
||||
Briefcase,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
FileText,
|
||||
MessageCircle
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface EmployeeInlineFormProps {
|
||||
onSave: (employeeData: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName?: string
|
||||
birthDate?: string
|
||||
phone: string
|
||||
email?: string
|
||||
position: string
|
||||
salary?: number
|
||||
avatar?: string
|
||||
telegram?: string
|
||||
whatsapp?: string
|
||||
passportPhoto?: string
|
||||
hireDate: string
|
||||
}) => void
|
||||
onCancel: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: EmployeeInlineFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
middleName: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
telegram: '',
|
||||
whatsapp: '',
|
||||
email: '',
|
||||
position: '',
|
||||
salary: 0,
|
||||
avatar: '',
|
||||
passportPhoto: ''
|
||||
})
|
||||
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
|
||||
const [showPassportPreview, setShowPassportPreview] = useState(false)
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||
const passportInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleInputChange = (field: string, value: string | number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
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') {
|
||||
// Для аватара используем upload-avatar API
|
||||
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
|
||||
endpoint = '/api/upload-avatar'
|
||||
} else {
|
||||
// Для паспорта используем специальный API для документов сотрудников
|
||||
formDataUpload.append('documentType', 'passport')
|
||||
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()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Неизвестная ошибка при загрузке')
|
||||
}
|
||||
|
||||
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 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)}`
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Валидация обязательных полей
|
||||
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
|
||||
toast.error('Пожалуйста, заполните все обязательные поля')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
toast.error('Введите корректный email адрес')
|
||||
return
|
||||
}
|
||||
|
||||
// Подготавливаем данные для отправки
|
||||
const employeeData = {
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
middleName: formData.middleName || undefined,
|
||||
birthDate: formData.birthDate || undefined,
|
||||
phone: formData.phone,
|
||||
email: formData.email || undefined,
|
||||
position: formData.position,
|
||||
salary: formData.salary || undefined,
|
||||
avatar: formData.avatar || undefined,
|
||||
telegram: formData.telegram || undefined,
|
||||
whatsapp: formData.whatsapp || undefined,
|
||||
passportPhoto: formData.passportPhoto || undefined,
|
||||
hireDate: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
onSave(employeeData)
|
||||
}
|
||||
|
||||
const getInitials = () => {
|
||||
const first = formData.firstName.charAt(0).toUpperCase()
|
||||
const last = formData.lastName.charAt(0).toUpperCase()
|
||||
return `${first}${last}`
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-20 w-20 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>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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="space-y-3">
|
||||
{formData.passportPhoto ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.passportPhoto}
|
||||
alt="Паспорт"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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"
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</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) => {
|
||||
const formatted = formatPhoneInput(e.target.value)
|
||||
handleInputChange('phone', formatted)
|
||||
}}
|
||||
placeholder="+7 (999) 123-45-67"
|
||||
className="glass-input text-white placeholder:text-white/40"
|
||||
required
|
||||
/>
|
||||
</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) => {
|
||||
const formatted = formatPhoneInput(e.target.value)
|
||||
handleInputChange('whatsapp', formatted)
|
||||
}}
|
||||
placeholder="+7 (999) 123-45-67"
|
||||
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">
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
required
|
||||
/>
|
||||
</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
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
{/* Превью паспорта */}
|
||||
<Dialog open={showPassportPreview} onOpenChange={setShowPassportPreview}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden glass-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Фото паспорта</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={formData.passportPhoto}
|
||||
alt="Паспорт"
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user