import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Employee, District, Position } from '../../types'; import { X, User, Phone, Calendar, MapPin, Briefcase, MessageCircle, Plus, Trash2, Upload, Image as ImageIcon, Receipt, FileCheck, CreditCard, Users } from 'lucide-react'; import { backendApi, authFetch } from '../../services/apiClient'; interface EmployeeFormModalProps { employee?: Employee | null; onClose: () => void; onSave: (employee: Employee) => void; } export const EmployeeFormModal: React.FC = ({ employee, onClose, onSave }) => { const isEditMode = !!employee; const [formData, setFormData] = useState({ name: employee?.name || '', position: employee?.position || '', phone: employee?.phone || '', status: employee?.status || 'active' as 'active' | 'vacation' | 'inactive', salary: employee?.salary || 0, assignedDistrictIds: (employee?.assignedDistrictIds?.length ? employee.assignedDistrictIds : (employee?.assignedDistrictId ? [employee.assignedDistrictId] : [])) as string[], managerId: employee?.managerId || '', birthDate: employee?.birthDate || '', photoUrl: employee?.photoUrl || '', registrationDate: employee?.registrationDate || '', messengerLogins: employee?.messengerLogins || [] as Array<{ messenger: 'Max' | 'Telegram'; login: string }>, hrData: { passportData: employee?.hrData?.passportData || undefined, laborBook: employee?.hrData?.laborBook || undefined, accountingData: employee?.hrData?.accountingData || undefined, contracts: employee?.hrData?.contracts || [], }, }); const [availableEmployees, setAvailableEmployees] = useState([]); const [loadingEmployees, setLoadingEmployees] = useState(false); const [districts, setDistricts] = useState([]); const [loadingDistricts, setLoadingDistricts] = useState(false); const [positions, setPositions] = useState([]); const [loadingPositions, setLoadingPositions] = useState(false); // Загружаем справочник должностей из API useEffect(() => { const loadPositions = async () => { try { setLoadingPositions(true); const list = await backendApi.getPositions(); setPositions(list); } catch (error) { console.error('Error loading positions:', error); setPositions([]); } finally { setLoadingPositions(false); } }; loadPositions(); }, []); // Загружаем список участков из API useEffect(() => { const loadDistricts = async () => { try { setLoadingDistricts(true); const list = await backendApi.getDistricts(); setDistricts(list); } catch (error) { console.error('Error loading districts:', error); setDistricts([]); } finally { setLoadingDistricts(false); } }; loadDistricts(); }, []); // Загружаем список сотрудников для выбора руководителя useEffect(() => { const loadEmployees = async () => { try { setLoadingEmployees(true); const employees = await backendApi.getEmployees(); const filtered = employees.filter(emp => emp.id !== employee?.id); setAvailableEmployees(filtered); } catch (error) { console.error('Error loading employees:', error); } finally { setLoadingEmployees(false); } }; loadEmployees(); }, [employee?.id]); // Руководители: только активные сотрудники (не уволенные) с руководящей должностью по справочнику. // Сравниваем должности с приведением к одному регистру и без лишних пробелов. // Если в справочнике нет руководящих должностей — показываем всех активных (чтобы форма работала до настройки справочника). const managerialPositionNamesLower = useMemo( () => new Set( positions .filter(p => p.isManagerial) .map(p => (p.name || '').trim().toLowerCase()) ), [positions] ); const hasAnyManagerialInReference = managerialPositionNamesLower.size > 0; const eligibleManagers = useMemo( () => availableEmployees.filter(emp => { if (emp.status !== 'active') return false; if (!hasAnyManagerialInReference) return true; // fallback: все активные, если справочник не настроен const empPos = (emp.position || '').trim().toLowerCase(); return empPos !== '' && managerialPositionNamesLower.has(empPos); }), [availableEmployees, managerialPositionNamesLower, hasAnyManagerialInReference] ); const [customPosition, setCustomPosition] = useState(''); const [showCustomPosition, setShowCustomPosition] = useState(false); // При загрузке должностей: если у сотрудника должность не из справочника — показываем поле «Другое» useEffect(() => { if (employee?.position && positions.length > 0) { const inList = positions.some(p => p.name === employee!.position); setShowCustomPosition(!inList); if (!inList) setCustomPosition(employee.position); } }, [employee?.position, positions]); const [photoFile, setPhotoFile] = useState(null); const [photoPreview, setPhotoPreview] = useState(employee?.photoUrl || null); const [isUploadingPhoto, setIsUploadingPhoto] = useState(false); const fileInputRef = useRef(null); const [newMessenger, setNewMessenger] = useState<{ messenger: 'Max' | 'Telegram'; login: string }>({ messenger: 'Telegram', login: '' }); const handlePhotoChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { // Проверяем тип файла if (!file.type.startsWith('image/')) { alert('Пожалуйста, выберите изображение'); return; } // Проверяем размер файла (5MB) if (file.size > 5 * 1024 * 1024) { alert('Размер файла не должен превышать 5MB'); return; } setPhotoFile(file); // Создаем превью const reader = new FileReader(); reader.onloadend = () => { setPhotoPreview(reader.result as string); }; reader.readAsDataURL(file); } }; const handleRemovePhoto = () => { setPhotoFile(null); setPhotoPreview(null); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Используем customPosition если выбрано "Другое" const finalPosition = showCustomPosition && customPosition ? customPosition : formData.position; if (!formData.name || !finalPosition || !formData.phone || !formData.salary) { alert('Заполните обязательные поля: ФИО, должность, телефон и оклад'); return; } setIsUploadingPhoto(true); let finalPhotoUrl = formData.photoUrl; let employeeId = employee?.id; // Если это новый сотрудник, сначала создаем его if (!employeeId) { try { // В режиме разработки используем относительные пути для работы через прокси Vite // В продакшене используем полный URL из переменной окружения const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const apiUrl = (import.meta.env.DEV || !apiBaseUrl) ? '/api/employees' : `${apiBaseUrl}/employees`; const newEmployeeData = { name: formData.name.trim(), position: finalPosition.trim(), phone: formData.phone.trim(), status: formData.status, salary: Number(formData.salary), assignedDistrictIds: formData.assignedDistrictIds && formData.assignedDistrictIds.length > 0 ? formData.assignedDistrictIds : [], managerId: formData.managerId && formData.managerId.trim() ? formData.managerId.trim() : null, birthDate: formData.birthDate && formData.birthDate.trim() ? formData.birthDate.trim() : null, registrationDate: formData.registrationDate && formData.registrationDate.trim() ? formData.registrationDate.trim() : null, messengerLogins: formData.messengerLogins.length > 0 ? formData.messengerLogins : undefined, hrData: { ...(formData.hrData.passportData && { passportData: formData.hrData.passportData }), ...(formData.hrData.laborBook && { laborBook: formData.hrData.laborBook }), ...(formData.hrData.accountingData && Object.keys(formData.hrData.accountingData).length > 0 && { accountingData: formData.hrData.accountingData }), ...(formData.hrData.contracts && formData.hrData.contracts.length > 0 && { contracts: formData.hrData.contracts }), }, }; const createResponse = await authFetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newEmployeeData) }); if (!createResponse.ok) { let errorMessage = 'Ошибка при создании сотрудника'; try { const errorData = await createResponse.json(); errorMessage = errorData.error || errorMessage; } catch (e) { // Если не удалось распарсить JSON, пытаемся прочитать текст ответа try { const text = await createResponse.text(); if (text) { errorMessage = text; } } catch (textError) { // Если и текст не удалось прочитать, используем статус errorMessage = `Ошибка ${createResponse.status}: ${createResponse.statusText}`; } } throw new Error(errorMessage); } const createdEmployee = await createResponse.json(); employeeId = createdEmployee.id; } catch (error) { console.error('Error creating employee:', error); const errorMessage = error instanceof Error ? error.message : 'Ошибка при создании сотрудника'; alert(errorMessage); setIsUploadingPhoto(false); return; } } // Загружаем фото, если оно было выбрано if (photoFile && employeeId) { try { const formDataPhoto = new FormData(); formDataPhoto.append('photo', photoFile); const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const photoUrl = (import.meta.env.DEV || !apiBaseUrl) ? `/api/employees/${employeeId}/photo` : `${apiBaseUrl}/employees/${employeeId}/photo`; const response = await authFetch(photoUrl, { method: 'POST', body: formDataPhoto }); if (!response.ok) { throw new Error('Ошибка при загрузке фото'); } const result = await response.json(); finalPhotoUrl = result.photoUrl; } catch (error) { console.error('Error uploading photo:', error); alert('Ошибка при загрузке фото. Продолжить без фото?'); } } setIsUploadingPhoto(false); // Если это был новый сотрудник, получаем его полные данные из API if (!employee && employeeId) { try { const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const employeeUrl = (import.meta.env.DEV || !apiBaseUrl) ? `/api/employees/${employeeId}` : `${apiBaseUrl}/employees/${employeeId}`; const response = await authFetch(employeeUrl); if (response.ok) { const fullEmployee = await response.json(); window.dispatchEvent(new CustomEvent('mkd-employees-changed')); onSave(fullEmployee); return; } } catch (error) { console.error('Error fetching created employee:', error); } } // Для существующего сотрудника обновляем данные через API if (employeeId) { try { const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const updateUrl = (import.meta.env.DEV || !apiBaseUrl) ? `/api/employees/${employeeId}` : `${apiBaseUrl}/employees/${employeeId}`; const updateData = { name: formData.name.trim(), position: finalPosition.trim(), phone: formData.phone.trim(), status: formData.status, salary: Number(formData.salary), assignedDistrictIds: formData.assignedDistrictIds && formData.assignedDistrictIds.length > 0 ? formData.assignedDistrictIds : [], managerId: formData.managerId && formData.managerId.trim() ? formData.managerId.trim() : null, birthDate: formData.birthDate && formData.birthDate.trim() ? formData.birthDate.trim() : null, photoUrl: finalPhotoUrl || undefined, registrationDate: formData.registrationDate && formData.registrationDate.trim() ? formData.registrationDate.trim() : null, messengerLogins: formData.messengerLogins.length > 0 ? formData.messengerLogins : undefined, hrData: { ...(formData.hrData.passportData && { passportData: formData.hrData.passportData }), ...(formData.hrData.laborBook && { laborBook: formData.hrData.laborBook }), ...(formData.hrData.accountingData && Object.keys(formData.hrData.accountingData).length > 0 && { accountingData: formData.hrData.accountingData }), ...(formData.hrData.contracts && formData.hrData.contracts.length > 0 && { contracts: formData.hrData.contracts }), }, }; const updateResponse = await authFetch(updateUrl, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updateData) }); if (!updateResponse.ok) { throw new Error('Ошибка при обновлении сотрудника'); } const updatedEmployee = await updateResponse.json(); window.dispatchEvent(new CustomEvent('mkd-employees-changed')); onSave(updatedEmployee); } catch (error) { console.error('Error updating employee:', error); alert('Ошибка при обновлении сотрудника'); } } else { // Для нового сотрудника (fallback) const employeeData: Employee = { id: employeeId!, name: formData.name, position: finalPosition, phone: formData.phone, status: formData.status, salary: formData.salary, assignedDistrictIds: formData.assignedDistrictIds || [], assignedDistrictId: formData.assignedDistrictIds?.[0] || undefined, managerId: formData.managerId || undefined, birthDate: formData.birthDate || undefined, photoUrl: finalPhotoUrl || undefined, registrationDate: formData.registrationDate || undefined, messengerLogins: formData.messengerLogins.length > 0 ? formData.messengerLogins : undefined, hrData: formData.hrData, }; window.dispatchEvent(new CustomEvent('mkd-employees-changed')); onSave(employeeData); } }; const addMessenger = () => { if (newMessenger.login.trim()) { setFormData({ ...formData, messengerLogins: [...formData.messengerLogins, newMessenger] }); setNewMessenger({ messenger: 'Telegram', login: '' }); } }; const removeMessenger = (index: number) => { setFormData({ ...formData, messengerLogins: formData.messengerLogins.filter((_, i) => i !== index) }); }; return (
e.stopPropagation()} > {/* Header */}

{isEditMode ? 'Редактирование сотрудника' : 'Добавление сотрудника'}

{/* Form */}
{/* Основная информация */}

Основная информация

setFormData({ ...formData, name: e.target.value })} placeholder="Иванов Иван Иванович" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" required />
{loadingPositions ? (
Загрузка справочника должностей...
) : ( <> {showCustomPosition && ( { setCustomPosition(e.target.value); setFormData({ ...formData, position: e.target.value }); }} placeholder="Введите должность" className="w-full mt-2 p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" required /> )} )}
setFormData({ ...formData, phone: e.target.value })} placeholder="+7 900 123-45-67" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" required />
setFormData({ ...formData, salary: parseFloat(e.target.value) || 0 })} placeholder="45000 или 45250.50" min="0" step="0.01" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" required />
{loadingDistricts ? (

Загрузка участков...

) : districts.length === 0 ? (

Нет участков

) : ( districts.map(district => ( )) )}

Только активные сотрудники с руководящей должностью из справочника

setFormData({ ...formData, birthDate: e.target.value })} className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, registrationDate: e.target.value })} className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
{photoPreview && (
Превью фото
)}
{isUploadingPhoto && ( Загрузка... )}

Поддерживаются форматы: JPG, PNG, GIF, WEBP. Максимальный размер: 5MB

{/* HR данные */}

HR данные

{/* Паспортные данные */}
Паспортные данные
setFormData({ ...formData, hrData: { ...formData.hrData, passportData: { ...formData.hrData.passportData, series: e.target.value, number: formData.hrData.passportData?.number || '', issuedBy: formData.hrData.passportData?.issuedBy || '', issuedDate: formData.hrData.passportData?.issuedDate || '', registrationAddress: formData.hrData.passportData?.registrationAddress || '', } } })} placeholder="1234" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, passportData: { ...formData.hrData.passportData, series: formData.hrData.passportData?.series || '', number: e.target.value, issuedBy: formData.hrData.passportData?.issuedBy || '', issuedDate: formData.hrData.passportData?.issuedDate || '', registrationAddress: formData.hrData.passportData?.registrationAddress || '', } } })} placeholder="567890" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, passportData: { ...formData.hrData.passportData, series: formData.hrData.passportData?.series || '', number: formData.hrData.passportData?.number || '', issuedBy: e.target.value, issuedDate: formData.hrData.passportData?.issuedDate || '', registrationAddress: formData.hrData.passportData?.registrationAddress || '', } } })} placeholder="УФМС России" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, passportData: { ...formData.hrData.passportData, series: formData.hrData.passportData?.series || '', number: formData.hrData.passportData?.number || '', issuedBy: formData.hrData.passportData?.issuedBy || '', issuedDate: e.target.value, registrationAddress: formData.hrData.passportData?.registrationAddress || '', } } })} className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, passportData: { ...formData.hrData.passportData, series: formData.hrData.passportData?.series || '', number: formData.hrData.passportData?.number || '', issuedBy: formData.hrData.passportData?.issuedBy || '', issuedDate: formData.hrData.passportData?.issuedDate || '', registrationAddress: e.target.value, } } })} placeholder="г. Москва, ул. Примерная, д. 1, кв. 1" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
{/* Бухгалтерская информация */}
Бухгалтерская информация
setFormData({ ...formData, hrData: { ...formData.hrData, accountingData: { ...formData.hrData.accountingData, inn: e.target.value, } } })} placeholder="123456789012" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, accountingData: { ...formData.hrData.accountingData, snils: e.target.value, } } })} placeholder="123-456-789 01" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, accountingData: { ...formData.hrData.accountingData, bankName: e.target.value, } } })} placeholder="ПАО Сбербанк" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, accountingData: { ...formData.hrData.accountingData, bankAccount: e.target.value, } } })} placeholder="40817810099910004312" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, accountingData: { ...formData.hrData.accountingData, correspondentAccount: e.target.value, } } })} placeholder="30101810400000000225" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, accountingData: { ...formData.hrData.accountingData, bik: e.target.value, } } })} placeholder="044525225" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
setFormData({ ...formData, hrData: { ...formData.hrData, accountingData: { ...formData.hrData.accountingData, taxId: e.target.value, } } })} placeholder="773101001" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
{/* Характеристики договора */}
Характеристики договора
{(formData.hrData.contracts || []).map((contract, idx) => (
Договор #{idx + 1}
{ const newContracts = [...(formData.hrData.contracts || [])]; newContracts[idx] = { ...newContracts[idx], contractNumber: e.target.value }; setFormData({ ...formData, hrData: { ...formData.hrData, contracts: newContracts } }); }} placeholder="ТД-2024-001" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
{ const newContracts = [...(formData.hrData.contracts || [])]; newContracts[idx] = { ...newContracts[idx], startDate: e.target.value }; setFormData({ ...formData, hrData: { ...formData.hrData, contracts: newContracts } }); }} className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" required />
{ const newContracts = [...(formData.hrData.contracts || [])]; newContracts[idx] = { ...newContracts[idx], endDate: e.target.value || undefined }; setFormData({ ...formData, hrData: { ...formData.hrData, contracts: newContracts } }); }} className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
{ const newContracts = [...(formData.hrData.contracts || [])]; newContracts[idx] = { ...newContracts[idx], probationPeriodDays: e.target.value ? parseInt(e.target.value) : undefined }; setFormData({ ...formData, hrData: { ...formData.hrData, contracts: newContracts } }); }} placeholder="90" min="0" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />