Files
sfera-new/src/components/employees/employee-inline-form.tsx
Veronika Smirnova c2b342a527 Исправлены критические ошибки типизации и React Hooks
• Исправлена ошибка React Hooks в EmployeesDashboard - перемещен useMemo на верхний уровень компонента
• Устранены ошибки TypeScript в ScheduleRecord интерфейсе
• Добавлена типизация GraphQL скаляров и резолверов
• Исправлены типы Apollo Client и error handling
• Очищены неиспользуемые импорты в компонентах Employee
• Переименованы неиспользуемые переменные в warehouse-statistics
• Исправлен экспорт RefreshCw иконки

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 14:25:30 +03:00

681 lines
28 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 {
Camera,
User,
X,
Save,
UserPlus,
AlertCircle,
RefreshCw,
FileImage,
Briefcase,
Phone,
Mail,
Calendar,
DollarSign,
MessageCircle,
} from 'lucide-react'
import Image from 'next/image'
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
formatPhoneInput,
formatSalary,
formatNameInput,
isValidEmail,
isValidPhone,
isValidBirthDate,
isValidSalary,
} from '@/lib/input-masks'
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
}
interface ValidationErrors {
[key: string]: string
}
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 [errors, setErrors] = useState<ValidationErrors>({})
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 'Введите корректный номер телефона в формате +7 (999) 123-45-67'
}
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') {
// Для аватара используем upload-avatar API и добавляем временный userId
formDataUpload.append('userId', `temp_${Date.now()}`)
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 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)
// Дебаг: показываем все ошибки в консоли
if (Object.keys(newErrors).filter((key) => newErrors[key]).length > 0) {
console.warn('Ошибки валидации:', 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,
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 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-6 mb-6">
<form onSubmit={handleSubmit}>
<div className="flex flex-col lg:flex-row gap-6">
{/* Информация о сотруднике - точно как в карточке */}
<div className="lg:w-80 flex-shrink-0">
<div className="flex items-start space-x-4 mb-4">
{/* Блок с аватаром и фото паспорта вертикально */}
<div className="flex flex-col items-center gap-4">
{/* Аватар с иконкой камеры */}
<div className="flex flex-col items-center gap-2">
<div className="relative">
<Avatar className="h-16 w-16 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 text-lg">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="avatar-upload-inline" 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>
<span className="text-white/60 text-xs text-center">Аватар</span>
</div>
{/* Фото паспорта */}
<div className="flex flex-col items-center gap-2">
<div className="relative">
<div className="w-16 h-16 rounded-lg ring-2 ring-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 cursor-pointer"
onClick={() => setShowPassportPreview(true)}
/>
) : (
<FileImage className="h-6 w-6 text-white/40" />
)}
</div>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="passport-upload-inline" className="cursor-pointer">
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center hover:bg-blue-700 transition-colors">
{isUploadingPassport ? (
<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>
<span className="text-white/60 text-xs text-center">Паспорт</span>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-semibold text-lg">
<UserPlus className="h-5 w-5 text-purple-400 inline mr-2" />
Новый сотрудник
</h3>
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={onCancel}
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mb-4">
<div className="space-y-3">
{/* Имя */}
<div className="flex items-center text-white/70">
<User className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Имя *"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.firstName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.firstName} />
</div>
</div>
{/* Фамилия */}
<div className="flex items-center text-white/70">
<User className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Фамилия *"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.lastName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.lastName} />
</div>
</div>
{/* Отчество */}
<div className="flex items-center text-white/70">
<User className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Отчество"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.middleName ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.middleName} />
</div>
</div>
{/* Должность */}
<div className="flex items-center text-white/70">
<Briefcase className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Должность *"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.position ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.position} />
</div>
</div>
{/* Телефон */}
<div className="flex items-center text-white/70">
<Phone className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="Телефон *"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.phone ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.phone} />
</div>
</div>
{/* Email */}
<div className="flex items-center text-white/70">
<Mail className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<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-9 ${errors.email ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.email} />
</div>
</div>
{/* Дата рождения */}
<div className="flex items-center text-white/70">
<Calendar className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
placeholder="Дата рождения"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.birthDate ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.birthDate} />
</div>
</div>
{/* Зарплата */}
<div className="flex items-center text-white/70">
<DollarSign className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<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-9 ${errors.salary ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.salary} />
</div>
</div>
{/* Telegram */}
<div className="flex items-center text-white/70">
<MessageCircle className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@telegram"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.telegram ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.telegram} />
</div>
</div>
{/* WhatsApp */}
<div className="flex items-center text-white/70">
<Phone className="h-4 w-4 mr-3 flex-shrink-0" />
<div className="flex-1">
<Input
value={formData.whatsapp}
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
placeholder="WhatsApp"
className={`glass-input text-white placeholder:text-white/40 h-9 ${errors.whatsapp ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.whatsapp} />
</div>
</div>
</div>
</div>
<div className="space-y-2 text-sm">
{/* Скрытые input элементы для загрузки файлов */}
<input
id="avatar-upload-inline"
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-inline"
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
disabled={isUploadingPassport}
/>
</div>
</div>
</div>
</div>
{/* Табель работы - точно как в карточке но пустой */}
<div className="flex-1 space-y-4">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Табель работы (будет доступен после создания)
</h4>
{/* Пустая сетка календаря */}
<div className="grid grid-cols-7 gap-2 opacity-50">
{/* Заголовки дней недели */}
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map((day) => (
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
{day}
</div>
))}
{/* Пустые дни месяца */}
{Array.from({ length: 35 }, (_, i) => {
const day = i + 1
if (day > 31) return <div key={i} className="p-2"></div>
return (
<div key={i} className="relative p-2 min-h-[60px] border rounded-lg bg-white/5 border-white/10">
<div className="flex flex-col items-center justify-center h-full">
<span className="font-semibold text-sm text-white/40">{day <= 31 ? day : ''}</span>
</div>
</div>
)
})}
</div>
{/* Статистика - пустая */}
<div className="grid grid-cols-4 gap-3 mt-4 opacity-50">
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-white/40 font-semibold text-lg">0</p>
<p className="text-white/40 text-xs">Рабочих дней</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-white/40 font-semibold text-lg">0</p>
<p className="text-white/40 text-xs">Отпуск</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-white/40 font-semibold text-lg">0</p>
<p className="text-white/40 text-xs">Больничный</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-white/40 font-semibold text-lg">0ч</p>
<p className="text-white/40 text-xs">Всего часов</p>
</div>
</div>
{/* Кнопка сохранения */}
<div className="flex justify-end pt-4">
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
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"
size="lg"
>
{isLoading ? (
'Создание сотрудника...'
) : (
<>
<Save className="h-4 w-4 mr-2" />
Создать сотрудника
</>
)}
</Button>
</div>
</div>
</div>
</form>
</Card>
{/* Превью паспорта */}
<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">
{formData.passportPhoto && formData.passportPhoto.trim() !== '' && (
<Image
src={formData.passportPhoto}
alt="Паспорт"
width={600}
height={800}
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
)}
</div>
</DialogContent>
</Dialog>
</>
)
}