Добавлены модели и функциональность для управления сотрудниками, включая создание, обновление и удаление сотрудников через GraphQL. Обновлены компоненты для отображения списка сотрудников и их расписания, улучшен интерфейс взаимодействия с пользователем. Реализованы функции экспорта отчетов в CSV и TXT форматах, добавлены новые интерфейсы и типы данных для сотрудников.

This commit is contained in:
Bivekich
2025-07-17 23:55:11 +03:00
parent 3e2a03da8c
commit d361364716
13 changed files with 3444 additions and 428 deletions

View 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>
)
}