import React, { useState, useEffect } from 'react'; import { Candidate, Vacancy } from '../../types'; import { User, Search, Filter, Phone, Mail, Briefcase, Calendar, Plus, Edit, Trash2, X, AlertCircle, FileText, CheckCircle, Clock, UserCheck, History } from 'lucide-react'; import { CandidateFormModal } from './CandidateFormModal'; import { CandidateEventsTimeline } from './CandidateEventsTimeline'; import { authFetch } from '../../services/apiClient'; export const CandidatesRegistry: React.FC = () => { const [candidates, setCandidates] = useState([]); const [vacancies, setVacancies] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [stageFilter, setStageFilter] = useState([]); const [vacancyFilter, setVacancyFilter] = useState(null); const [isFormModalOpen, setIsFormModalOpen] = useState(false); const [editingCandidate, setEditingCandidate] = useState(null); const [selectedVacancyId, setSelectedVacancyId] = useState(null); const [selectedCandidateForEvents, setSelectedCandidateForEvents] = useState(null); const [error, setError] = useState(null); useEffect(() => { fetchCandidates(); fetchVacancies(); }, []); const fetchCandidates = async () => { try { setLoading(true); setError(null); const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const apiUrl = (import.meta.env.DEV || !apiBaseUrl) ? '/api/candidates?includeEvents=true' : `${apiBaseUrl}/candidates?includeEvents=true`; const response = await authFetch(apiUrl); if (response.ok) { const data = await response.json(); setCandidates(data); } else { const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); const errorMessage = errorData.message || errorData.error || `HTTP ${response.status}: ${response.statusText}`; setError(errorMessage); console.error('Error fetching candidates:', errorMessage, errorData); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch candidates'; setError(errorMessage); console.error('Error fetching candidates:', error); } finally { setLoading(false); } }; const fetchVacancies = async () => { try { 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(); setVacancies(data); } } catch (error) { console.error('Error fetching vacancies:', error); } }; const handleCreateCandidate = (vacancyId?: string | null) => { setEditingCandidate(null); setSelectedVacancyId(vacancyId || null); setIsFormModalOpen(true); }; const handleEditCandidate = (candidate: Candidate) => { setEditingCandidate(candidate); setSelectedVacancyId(null); setIsFormModalOpen(true); }; const handleDeleteCandidate = async (candidate: Candidate) => { if (!confirm(`Удалить кандидата "${candidate.name}"?`)) return; try { const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const apiUrl = (import.meta.env.DEV || !apiBaseUrl) ? `/api/candidates/${candidate.id}` : `${apiBaseUrl}/candidates/${candidate.id}`; const response = await authFetch(apiUrl, { method: 'DELETE' }); if (response.ok) { await fetchCandidates(); } else { alert('Ошибка при удалении кандидата'); } } catch (error) { console.error('Error deleting candidate:', error); alert('Ошибка при удалении кандидата'); } }; const handleSaveCandidate = async (candidate: Candidate) => { await fetchCandidates(); setIsFormModalOpen(false); setEditingCandidate(null); setSelectedVacancyId(null); }; const toggleStageFilter = (stage: string) => { setStageFilter(prev => prev.includes(stage) ? prev.filter(s => s !== stage) : [...prev, stage] ); }; const filteredCandidates = candidates.filter(c => { const query = searchQuery.toLowerCase(); const matchesSearch = c.name.toLowerCase().includes(query) || c.position.toLowerCase().includes(query) || (c.phone && c.phone.toLowerCase().includes(query)) || (c.email && c.email.toLowerCase().includes(query)); const matchesStage = stageFilter.length === 0 || stageFilter.includes(c.stage); const matchesVacancy = !vacancyFilter || c.vacancyId === vacancyFilter; return matchesSearch && matchesStage && matchesVacancy; }); const getStageLabel = (stage: string) => { switch (stage) { case 'new': return 'Новый'; case 'interview': return 'Собеседование'; case 'probation': return 'Испытательный срок'; case 'hired': return 'Трудоустроен'; case 'rejected': return 'Отклонен'; default: return stage; } }; const getStageColor = (stage: string) => { switch (stage) { case 'new': return 'bg-blue-50 text-blue-600'; case 'interview': return 'bg-amber-50 text-amber-600'; case 'probation': return 'bg-purple-50 text-purple-600'; case 'hired': return 'bg-emerald-50 text-emerald-600'; case 'rejected': return 'bg-red-50 text-red-600'; default: return 'bg-slate-50 text-slate-600'; } }; const getStageIcon = (stage: string) => { switch (stage) { case 'new': return ; case 'interview': return ; case 'probation': return ; case 'hired': return ; case 'rejected': return ; default: return ; } }; const getVacancyName = (vacancyId?: string) => { if (!vacancyId) return 'Без вакансии'; const vacancy = vacancies.find(v => v.id === vacancyId); return vacancy ? vacancy.position : 'Неизвестная вакансия'; }; const stats = { total: candidates.length, new: candidates.filter(c => c.stage === 'new').length, interview: candidates.filter(c => c.stage === 'interview').length, probation: candidates.filter(c => c.stage === 'probation').length, hired: candidates.filter(c => c.stage === 'hired').length, rejected: candidates.filter(c => c.stage === 'rejected').length, }; return (
{/* Toolbar */}
setSearchQuery(e.target.value)} className="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm" />
{/* Stats Header */}
Кандидаты

База кандидатов

Всего {stats.total} кандидатов в базе. Активных на собеседовании: {stats.interview + stats.probation}.

Новые

{stats.new}

Собеседование

{stats.interview}

Испытательный срок

{stats.probation}

Трудоустроены

{stats.hired}

{/* Filters */}
{/* Фильтр по статусу */}
{['new', 'interview', 'probation', 'hired', 'rejected'].map(stage => ( ))}
{/* Фильтр по вакансии */}
{/* Кнопка создания кандидата */} {/* Error Message */} {error && (

Ошибка загрузки кандидатов

{error}

)} {/* List of Candidates */} {loading ? (
Загрузка...
) : error ? null : filteredCandidates.length === 0 ? (
{searchQuery || stageFilter.length > 0 || vacancyFilter ? 'Кандидаты не найдены' : 'Нет кандидатов. Создайте первого кандидата.'}
) : (
{filteredCandidates.map(candidate => (
{/* Status Vertical Line */}
{getStageIcon(candidate.stage)} {getStageLabel(candidate.stage)} {candidate.vacancyId && ( {getVacancyName(candidate.vacancyId)} )} {!candidate.vacancyId && ( Без вакансии )}

{candidate.name}

{candidate.position}

{candidate.phone && (
{candidate.phone}
)} {candidate.email && (
{candidate.email}
)} {candidate.interviewDate && (
Собеседование: {new Date(candidate.interviewDate).toLocaleDateString('ru-RU')}
)} {candidate.hiredDate && (
Трудоустроен: {new Date(candidate.hiredDate).toLocaleDateString('ru-RU')}
)}
{candidate.interviewNotes && (

Заметки с собеседования

{candidate.interviewNotes}

)} {candidate.events && candidate.events.length > 0 && (

Последнее событие

{(() => { const lastEvent = candidate.events[0]; const eventDate = new Date(lastEvent.eventDate); const eventLabels: Record = { call: 'Созвон', interview_1: 'Первое собеседование', interview_2: 'Второе собеседование', interview_3: 'Третье собеседование', test_task: 'Тестовое задание', offer: 'Оффер', offer_accepted: 'Оффер принят', offer_rejected: 'Оффер отклонен', probation_start: 'Начало испытательного срока', hired: 'Трудоустроен', rejected: 'Отклонен', other: 'Другое' }; return `${eventLabels[lastEvent.eventType] || lastEvent.eventType} - ${eventDate.toLocaleDateString('ru-RU')}`; })()}

)}
))}
)} {/* Модальное окно создания/редактирования кандидата */} {isFormModalOpen && ( { setIsFormModalOpen(false); setEditingCandidate(null); setSelectedVacancyId(null); }} onSave={handleSaveCandidate} /> )} {/* Модальное окно истории событий кандидата */} {selectedCandidateForEvents && (
setSelectedCandidateForEvents(null)} >
e.stopPropagation()} >

История событий

{selectedCandidateForEvents.name}

{ fetchCandidates(); }} onEventUpdated={() => { fetchCandidates(); }} onCandidateUpdated={(updatedCandidate) => { // Обновляем кандидата в списке setCandidates(prev => prev.map(c => c.id === updatedCandidate.id ? updatedCandidate : c) ); // Обновляем выбранного кандидата setSelectedCandidateForEvents(updatedCandidate); fetchCandidates(); }} />
)}
); };