Files
mkd/components/hr/CandidateFormModal.tsx
2026-02-04 00:17:04 +05:00

467 lines
26 KiB
TypeScript
Executable File
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.
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>
);
};