Files
mkd/components/hr/CandidatesRegistry.tsx

500 lines
29 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 { 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<Candidate[]>([]);
const [vacancies, setVacancies] = useState<Vacancy[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [stageFilter, setStageFilter] = useState<string[]>([]);
const [vacancyFilter, setVacancyFilter] = useState<string | null>(null);
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
const [editingCandidate, setEditingCandidate] = useState<Candidate | null>(null);
const [selectedVacancyId, setSelectedVacancyId] = useState<string | null>(null);
const [selectedCandidateForEvents, setSelectedCandidateForEvents] = useState<Candidate | null>(null);
const [error, setError] = useState<string | null>(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 <Clock className="w-4 h-4" />;
case 'interview': return <Calendar className="w-4 h-4" />;
case 'probation': return <UserCheck className="w-4 h-4" />;
case 'hired': return <CheckCircle className="w-4 h-4" />;
case 'rejected': return <X className="w-4 h-4" />;
default: return <User className="w-4 h-4" />;
}
};
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 (
<div className="space-y-6 animate-fade-in">
{/* Toolbar */}
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск кандидатов по имени, позиции, телефону..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<button className="p-3 bg-white border border-slate-200 rounded-2xl text-slate-500 hover:bg-slate-50 transition-all shadow-sm">
<Filter className="w-5 h-5"/>
</button>
</div>
{/* Stats Header */}
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
<User className="absolute -bottom-4 -right-4 w-48 h-48 opacity-10 rotate-12" />
<div className="relative z-10">
<div className="flex items-center gap-2 text-primary-400 mb-2">
<Briefcase className="w-5 h-5"/>
<span className="text-[10px] font-black uppercase tracking-widest">Кандидаты</span>
</div>
<h3 className="text-3xl font-black mb-2">База кандидатов</h3>
<p className="text-xs text-slate-400 font-medium mb-8 max-w-md">
Всего {stats.total} кандидатов в базе. Активных на собеседовании: {stats.interview + stats.probation}.
</p>
<div className="flex gap-4 flex-wrap">
<div className="bg-white/10 px-4 py-2 rounded-xl border border-white/10">
<p className="text-[10px] text-slate-400 font-bold uppercase mb-0.5">Новые</p>
<p className="text-xl font-black text-white">{stats.new}</p>
</div>
<div className="bg-white/10 px-4 py-2 rounded-xl border border-white/10">
<p className="text-[10px] text-slate-400 font-bold uppercase mb-0.5">Собеседование</p>
<p className="text-xl font-black text-white">{stats.interview}</p>
</div>
<div className="bg-white/10 px-4 py-2 rounded-xl border border-white/10">
<p className="text-[10px] text-slate-400 font-bold uppercase mb-0.5">Испытательный срок</p>
<p className="text-xl font-black text-white">{stats.probation}</p>
</div>
<div className="bg-white/10 px-4 py-2 rounded-xl border border-white/10">
<p className="text-[10px] text-slate-400 font-bold uppercase mb-0.5">Трудоустроены</p>
<p className="text-xl font-black text-white">{stats.hired}</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Фильтр по статусу */}
<div>
<label className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2 block">
Статус
</label>
<div className="flex flex-wrap gap-2">
{['new', 'interview', 'probation', 'hired', 'rejected'].map(stage => (
<button
key={stage}
onClick={() => toggleStageFilter(stage)}
className={`px-3 py-1.5 rounded-xl text-[10px] font-black uppercase transition-all ${
stageFilter.includes(stage)
? getStageColor(stage)
: 'bg-slate-50 text-slate-400 hover:bg-slate-100'
}`}
>
{getStageLabel(stage)}
</button>
))}
</div>
</div>
{/* Фильтр по вакансии */}
<div>
<label className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2 block">
Вакансия
</label>
<select
value={vacancyFilter || ''}
onChange={(e) => setVacancyFilter(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"
>
<option value="">Все вакансии</option>
<option value="null">Без вакансии</option>
{vacancies.map(vacancy => (
<option key={vacancy.id} value={vacancy.id}>
{vacancy.position} ({vacancy.department})
</option>
))}
</select>
</div>
</div>
</div>
{/* Кнопка создания кандидата */}
<button
onClick={() => handleCreateCandidate()}
className="w-full py-4 bg-primary-600 text-white rounded-2xl text-sm font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 active:scale-95 transition-all"
>
<Plus className="w-5 h-5"/> Создать нового кандидата
</button>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-2xl p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-bold text-red-800 mb-1">Ошибка загрузки кандидатов</h4>
<p className="text-sm text-red-700">{error}</p>
<button
onClick={fetchCandidates}
className="mt-3 text-sm font-medium text-red-600 hover:text-red-800 underline"
>
Попробовать снова
</button>
</div>
</div>
)}
{/* List of Candidates */}
{loading ? (
<div className="text-center py-10 text-slate-400">Загрузка...</div>
) : error ? null : filteredCandidates.length === 0 ? (
<div className="text-center py-10 text-slate-400">
{searchQuery || stageFilter.length > 0 || vacancyFilter ? 'Кандидаты не найдены' : 'Нет кандидатов. Создайте первого кандидата.'}
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{filteredCandidates.map(candidate => (
<div key={candidate.id} className="bg-white p-6 rounded-[2.5rem] border border-slate-200 shadow-sm hover:shadow-lg hover:border-primary-300 transition-all group relative overflow-hidden">
{/* Status Vertical Line */}
<div className={`absolute left-0 top-1/4 bottom-1/4 w-1.5 rounded-r-full ${
candidate.stage === 'new' ? 'bg-blue-500' :
candidate.stage === 'interview' ? 'bg-amber-500' :
candidate.stage === 'probation' ? 'bg-purple-500' :
candidate.stage === 'hired' ? 'bg-emerald-500' :
'bg-red-500'
}`}/>
<div className="flex flex-col md:flex-row justify-between gap-6">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase tracking-tighter flex items-center gap-1 ${getStageColor(candidate.stage)}`}>
{getStageIcon(candidate.stage)}
{getStageLabel(candidate.stage)}
</span>
{candidate.vacancyId && (
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest px-2 py-0.5 bg-slate-50 rounded-full border border-slate-100">
{getVacancyName(candidate.vacancyId)}
</span>
)}
{!candidate.vacancyId && (
<span className="text-[9px] font-black text-slate-300 uppercase tracking-widest px-2 py-0.5 bg-slate-50 rounded-full border border-slate-100">
Без вакансии
</span>
)}
</div>
<h4 className="text-xl font-black text-slate-800 leading-tight group-hover:text-primary-600 transition-colors mb-2">{candidate.name}</h4>
<p className="text-xs text-slate-500 font-medium leading-relaxed mb-3">{candidate.position}</p>
<div className="flex flex-wrap gap-4 text-xs text-slate-600">
{candidate.phone && (
<div className="flex items-center gap-1.5">
<Phone className="w-3.5 h-3.5 text-slate-400"/>
<span>{candidate.phone}</span>
</div>
)}
{candidate.email && (
<div className="flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5 text-slate-400"/>
<span>{candidate.email}</span>
</div>
)}
{candidate.interviewDate && (
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-slate-400"/>
<span>Собеседование: {new Date(candidate.interviewDate).toLocaleDateString('ru-RU')}</span>
</div>
)}
{candidate.hiredDate && (
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-emerald-500"/>
<span>Трудоустроен: {new Date(candidate.hiredDate).toLocaleDateString('ru-RU')}</span>
</div>
)}
</div>
{candidate.interviewNotes && (
<div className="mt-3 p-3 bg-slate-50 rounded-xl border border-slate-100">
<p className="text-[10px] font-black text-slate-400 uppercase mb-1">Заметки с собеседования</p>
<p className="text-xs text-slate-600">{candidate.interviewNotes}</p>
</div>
)}
{candidate.events && candidate.events.length > 0 && (
<div className="mt-3 p-3 bg-primary-50 rounded-xl border border-primary-100">
<p className="text-[10px] font-black text-primary-600 uppercase mb-1 flex items-center gap-1">
<History className="w-3 h-3"/> Последнее событие
</p>
<p className="text-xs text-primary-700 font-medium">
{(() => {
const lastEvent = candidate.events[0];
const eventDate = new Date(lastEvent.eventDate);
const eventLabels: Record<string, string> = {
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')}`;
})()}
</p>
</div>
)}
</div>
<div className="text-right flex flex-col justify-between items-end min-w-[150px]">
<div className="flex gap-2">
<button
onClick={() => setSelectedCandidateForEvents(candidate)}
className="p-2 text-slate-300 hover:text-purple-600 transition-colors"
title="История событий"
>
<History className="w-5 h-5"/>
</button>
<button
onClick={() => handleEditCandidate(candidate)}
className="p-2 text-slate-300 hover:text-primary-600 transition-colors"
title="Редактировать"
>
<Edit className="w-5 h-5"/>
</button>
<button
onClick={() => handleDeleteCandidate(candidate)}
className="p-2 text-slate-300 hover:text-red-600 transition-colors"
title="Удалить"
>
<Trash2 className="w-5 h-5"/>
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Модальное окно создания/редактирования кандидата */}
{isFormModalOpen && (
<CandidateFormModal
candidate={editingCandidate}
vacancyId={selectedVacancyId}
vacancies={vacancies}
onClose={() => {
setIsFormModalOpen(false);
setEditingCandidate(null);
setSelectedVacancyId(null);
}}
onSave={handleSaveCandidate}
/>
)}
{/* Модальное окно истории событий кандидата */}
{selectedCandidateForEvents && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={() => setSelectedCandidateForEvents(null)}
>
<div
className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up"
onClick={e => e.stopPropagation()}
>
<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">
<div>
<h3 className="text-2xl font-bold text-slate-800">История событий</h3>
<p className="text-sm text-slate-500 mt-1">{selectedCandidateForEvents.name}</p>
</div>
<button
onClick={() => setSelectedCandidateForEvents(null)}
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-500"/>
</button>
</div>
</div>
<div className="p-6">
<CandidateEventsTimeline
candidate={selectedCandidateForEvents}
onEventAdded={() => {
fetchCandidates();
}}
onEventUpdated={() => {
fetchCandidates();
}}
onCandidateUpdated={(updatedCandidate) => {
// Обновляем кандидата в списке
setCandidates(prev =>
prev.map(c => c.id === updatedCandidate.id ? updatedCandidate : c)
);
// Обновляем выбранного кандидата
setSelectedCandidateForEvents(updatedCandidate);
fetchCandidates();
}}
/>
</div>
</div>
</div>
)}
</div>
);
};