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

500 lines
29 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 { 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>
);
};