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