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