Files
mkd/components/hr/CandidateFormModal.tsx

467 lines
26 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import { Candidate, Vacancy } from '../../types';
import { X, User, Phone, Mail, FileText, Briefcase, Calendar, MessageSquare, DollarSign } from 'lucide-react';
import { CandidateEventsTimeline } from './CandidateEventsTimeline';
import { authFetch } from '../../services/apiClient';
interface CandidateFormModalProps {
candidate?: Candidate | null;
vacancyId?: string | null;
vacancies?: Vacancy[];
onClose: () => void;
onSave: (candidate: Candidate) => void;
}
export const CandidateFormModal: React.FC<CandidateFormModalProps> = ({
candidate,
vacancyId: initialVacancyId,
vacancies = [],
onClose,
onSave
}) => {
const isEditMode = !!candidate;
const [formData, setFormData] = useState({
name: candidate?.name || '',
position: candidate?.position || '',
vacancyId: candidate?.vacancyId || initialVacancyId || '',
stage: candidate?.stage || 'new' as 'new' | 'interview' | 'probation' | 'hired' | 'rejected',
phone: candidate?.phone || '',
email: candidate?.email || '',
resumeUrl: candidate?.resumeUrl || '',
coverLetter: candidate?.coverLetter || '',
interviewDate: candidate?.interviewDate ? new Date(candidate.interviewDate).toISOString().split('T')[0] : '',
interviewNotes: candidate?.interviewNotes || '',
offerSalary: candidate?.offerSalary?.toString() || '',
offerDate: candidate?.offerDate || '',
hiredDate: candidate?.hiredDate || '',
rejectedReason: candidate?.rejectedReason || '',
});
const [loadingVacancies, setLoadingVacancies] = useState(false);
const [availableVacancies, setAvailableVacancies] = useState<Vacancy[]>(vacancies);
// Загружаем вакансии если не переданы
useEffect(() => {
if (vacancies.length === 0) {
const fetchVacancies = async () => {
try {
setLoadingVacancies(true);
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const apiUrl = (import.meta.env.DEV || !apiBaseUrl)
? '/api/vacancies'
: `${apiBaseUrl}/vacancies`;
const response = await authFetch(apiUrl);
if (response.ok) {
const data = await response.json();
setAvailableVacancies(data);
}
} catch (error) {
console.error('Error fetching vacancies:', error);
} finally {
setLoadingVacancies(false);
}
};
fetchVacancies();
}
}, [vacancies.length]);
// Обновляем позицию при выборе вакансии
useEffect(() => {
if (formData.vacancyId) {
const selectedVacancy = availableVacancies.find(v => v.id === formData.vacancyId);
if (selectedVacancy && !isEditMode) {
setFormData(prev => ({ ...prev, position: selectedVacancy.position }));
}
}
}, [formData.vacancyId, availableVacancies, isEditMode]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.position || !formData.phone) {
alert('Заполните обязательные поля: ФИО, позиция и телефон');
return;
}
try {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const apiUrl = (import.meta.env.DEV || !apiBaseUrl)
? `/api/candidates${isEditMode ? `/${candidate.id}` : ''}`
: `${apiBaseUrl}/candidates${isEditMode ? `/${candidate.id}` : ''}`;
const method = isEditMode ? 'PUT' : 'POST';
const body = {
...(isEditMode ? {} : { id: `cand-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` }),
name: formData.name.trim(),
position: formData.position.trim(),
vacancyId: formData.vacancyId && formData.vacancyId.trim() ? formData.vacancyId.trim() : null,
stage: formData.stage,
phone: formData.phone.trim(),
email: formData.email.trim() || null,
resumeUrl: formData.resumeUrl.trim() || null,
coverLetter: formData.coverLetter.trim() || null,
interviewDate: formData.interviewDate || null,
interviewNotes: formData.interviewNotes.trim() || null,
offerSalary: formData.offerSalary ? parseFloat(formData.offerSalary) : null,
offerDate: formData.offerDate || null,
hiredDate: formData.hiredDate || null,
rejectedReason: formData.rejectedReason.trim() || null,
};
const response = await authFetch(apiUrl, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
let errorMessage = `Ошибка ${response.status}: ${response.statusText}`;
try {
const text = await response.text();
if (text) {
try {
const errorData = JSON.parse(text);
errorMessage = errorData.error || errorData.message || text;
} catch {
errorMessage = text;
}
}
} catch (textError) {
console.error('Failed to read error response:', textError);
}
throw new Error(errorMessage);
}
const savedCandidate = await response.json();
onSave(savedCandidate);
} catch (error) {
console.error('Error saving candidate:', error);
let errorMessage = 'Ошибка при сохранении кандидата';
if (error instanceof Error) {
errorMessage = error.message;
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
errorMessage = 'Ошибка сети. Проверьте подключение к серверу.';
}
}
alert(errorMessage);
}
};
const getStageLabel = (stage: string) => {
switch (stage) {
case 'new': return 'Новый';
case 'interview': return 'Собеседование';
case 'probation': return 'Испытательный срок';
case 'hired': return 'Трудоустроен';
case 'rejected': return 'Отклонен';
default: return stage;
}
};
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
<div
className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 bg-white border-b border-slate-200 p-6 rounded-t-2xl z-10">
<div className="flex justify-between items-center">
<h3 className="text-2xl font-bold text-slate-800">
{isEditMode ? 'Редактирование кандидата' : 'Создание нового кандидата'}
</h3>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-500"/>
</button>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Основная информация */}
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<User className="w-4 h-4"/> Основная информация
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
ФИО *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<Phone className="w-3 h-3"/> Телефон *
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="+7 (999) 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
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<Mail className="w-3 h-3"/> Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="ivanov@example.com"
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Вакансия
</label>
<select
value={formData.vacancyId || ''}
onChange={(e) => setFormData({ ...formData, vacancyId: e.target.value || null })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
disabled={loadingVacancies}
>
<option value="">Без вакансии</option>
{availableVacancies.map(vacancy => (
<option key={vacancy.id} value={vacancy.id}>
{vacancy.position} ({vacancy.department})
</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<Briefcase className="w-3 h-3"/> Позиция *
</label>
<input
type="text"
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: 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
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Статус
</label>
<select
value={formData.stage}
onChange={(e) => setFormData({ ...formData, stage: e.target.value as any })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="new">Новый</option>
<option value="interview">Собеседование</option>
<option value="probation">Испытательный срок</option>
<option value="hired">Трудоустроен</option>
<option value="rejected">Отклонен</option>
</select>
</div>
</div>
</div>
{/* Документы и материалы */}
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<FileText className="w-4 h-4"/> Документы и материалы
</h4>
<div className="space-y-4">
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Ссылка на резюме
</label>
<input
type="url"
value={formData.resumeUrl}
onChange={(e) => setFormData({ ...formData, resumeUrl: e.target.value })}
placeholder="https://..."
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Сопроводительное письмо
</label>
<textarea
value={formData.coverLetter}
onChange={(e) => setFormData({ ...formData, coverLetter: e.target.value })}
placeholder="Текст сопроводительного письма..."
rows={3}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
</div>
{/* Собеседование */}
{(formData.stage === 'interview' || formData.stage === 'probation' || formData.stage === 'hired' || candidate?.interviewDate) && (
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Собеседование
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Дата собеседования
</label>
<input
type="date"
value={formData.interviewDate}
onChange={(e) => setFormData({ ...formData, interviewDate: 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"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<MessageSquare className="w-3 h-3"/> Заметки с собеседования
</label>
<textarea
value={formData.interviewNotes}
onChange={(e) => setFormData({ ...formData, interviewNotes: e.target.value })}
placeholder="Заметки и впечатления с собеседования..."
rows={4}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
)}
{/* Предложение */}
{(formData.stage === 'probation' || formData.stage === 'hired' || candidate?.offerDate) && (
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<DollarSign className="w-4 h-4"/> Предложение
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Предложенная зарплата
</label>
<input
type="number"
value={formData.offerSalary}
onChange={(e) => setFormData({ ...formData, offerSalary: e.target.value })}
placeholder="55000"
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Дата предложения
</label>
<input
type="date"
value={formData.offerDate}
onChange={(e) => setFormData({ ...formData, offerDate: 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"
/>
</div>
</div>
</div>
)}
{/* Трудоустройство */}
{formData.stage === 'hired' && (
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<User className="w-4 h-4"/> Трудоустройство
</h4>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Дата трудоустройства
</label>
<input
type="date"
value={formData.hiredDate}
onChange={(e) => setFormData({ ...formData, hiredDate: 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"
/>
</div>
</div>
)}
{/* Отказ */}
{formData.stage === 'rejected' && (
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<X className="w-4 h-4"/> Отказ
</h4>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Причина отказа
</label>
<textarea
value={formData.rejectedReason}
onChange={(e) => setFormData({ ...formData, rejectedReason: e.target.value })}
placeholder="Причина отказа кандидату..."
rows={3}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
)}
{/* История событий (только в режиме редактирования) */}
{isEditMode && candidate && (
<div className="pt-6 border-t border-slate-200">
<CandidateEventsTimeline
candidate={{
...candidate,
id: candidate.id
}}
onEventAdded={() => {
// Можно обновить данные кандидата если нужно
}}
onCandidateUpdated={(updatedCandidate) => {
// Обновляем кандидата в форме
// Это обновит отображаемые данные
}}
/>
</div>
)}
{/* Кнопки */}
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 bg-slate-50 text-slate-700 rounded-xl text-xs font-black uppercase hover:bg-slate-100 transition-all"
>
Отмена
</button>
<button
type="submit"
className="flex-1 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase shadow-lg shadow-primary-500/20 hover:bg-primary-700 active:scale-95 transition-all"
>
{isEditMode ? 'Сохранить' : 'Создать кандидата'}
</button>
</div>
</form>
</div>
</div>
);
};