Initial commit MKD fixes
This commit is contained in:
466
components/hr/CandidateFormModal.tsx
Executable file
466
components/hr/CandidateFormModal.tsx
Executable file
@@ -0,0 +1,466 @@
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user