import React, { useState, useEffect } from 'react'; import { CandidateEvent, Candidate } from '../../types'; import { Phone, Calendar, FileText, CheckCircle, X, Clock, User, MapPin, Plus, Edit, Trash2, AlertCircle, Building2 } from 'lucide-react'; import { BookMeetingRoomModal } from '../office/BookMeetingRoomModal'; import { authFetch } from '../../services/apiClient'; interface CandidateEventsTimelineProps { candidate: Candidate; onEventAdded?: () => void; onEventUpdated?: () => void; onCandidateUpdated?: (updatedCandidate: Candidate) => void; } interface EventFormData { eventType: CandidateEvent['eventType']; eventDate: string; eventTime: string; notes?: string; result?: CandidateEvent['result']; interviewer?: string; location?: string; durationMinutes?: number; } export const CandidateEventsTimeline: React.FC = ({ candidate, onEventAdded, onEventUpdated, onCandidateUpdated }) => { const [events, setEvents] = useState(candidate.events || []); const [isFormOpen, setIsFormOpen] = useState(false); const [editingEvent, setEditingEvent] = useState(null); const [loading, setLoading] = useState(false); const [showBookRoomModal, setShowBookRoomModal] = useState(false); useEffect(() => { fetchEvents(); }, [candidate.id]); const fetchEvents = async () => { try { setLoading(true); const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const apiUrl = (import.meta.env.DEV || !apiBaseUrl) ? `/api/candidates/${candidate.id}/events` : `${apiBaseUrl}/candidates/${candidate.id}/events`; const response = await authFetch(apiUrl); if (response.ok) { const data = await response.json(); // Убеждаемся, что данные правильно маппятся const mappedEvents = data.map((event: any) => ({ ...event, eventType: event.eventType || event.event_type, eventDate: event.eventDate || event.event_date, candidateId: event.candidateId || event.candidate_id, durationMinutes: event.durationMinutes || event.duration_minutes, createdAt: event.createdAt || event.created_at, updatedAt: event.updatedAt || event.updated_at })); setEvents(mappedEvents); } // Также обновляем данные кандидата, так как статус мог измениться const candidateUrl = (import.meta.env.DEV || !apiBaseUrl) ? `/api/candidates/${candidate.id}` : `${apiBaseUrl}/candidates/${candidate.id}`; const candidateResponse = await authFetch(candidateUrl); if (candidateResponse.ok) { const updatedCandidate = await candidateResponse.json(); onCandidateUpdated?.(updatedCandidate); } } catch (error) { console.error('Error fetching events:', error); } finally { setLoading(false); } }; const handleAddEvent = () => { setEditingEvent(null); setIsFormOpen(true); }; const handleEditEvent = (event: CandidateEvent) => { setEditingEvent(event); setIsFormOpen(true); }; const handleDeleteEvent = async (event: CandidateEvent) => { if (!confirm(`Удалить событие "${getEventTypeLabel(event.eventType)}"?`)) return; try { const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const apiUrl = (import.meta.env.DEV || !apiBaseUrl) ? `/api/candidates/${candidate.id}/events/${event.id}` : `${apiBaseUrl}/candidates/${candidate.id}/events/${event.id}`; const response = await authFetch(apiUrl, { method: 'DELETE' }); if (response.ok) { await fetchEvents(); onEventUpdated?.(); } else { alert('Ошибка при удалении события'); } } catch (error) { console.error('Error deleting event:', error); alert('Ошибка при удалении события'); } }; const getEventTypeLabel = (type: CandidateEvent['eventType'] | string | undefined) => { if (!type) return 'Неизвестное событие'; const labels: Record = { call: 'Созвон', interview_1: 'Первое собеседование', interview_2: 'Второе собеседование', interview_3: 'Третье собеседование', test_task: 'Тестовое задание', offer: 'Оффер', offer_accepted: 'Оффер принят', offer_rejected: 'Оффер отклонен', probation_start: 'Начало испытательного срока', hired: 'Трудоустроен', rejected: 'Отклонен', other: 'Другое' }; return labels[type] || `Событие: ${type}`; }; const getEventTypeIcon = (type: CandidateEvent['eventType'] | string | undefined) => { if (!type) return ; switch (type) { case 'call': return ; case 'interview_1': case 'interview_2': case 'interview_3': return ; case 'test_task': return ; case 'offer': case 'offer_accepted': return ; case 'offer_rejected': case 'rejected': return ; case 'probation_start': case 'hired': return ; default: return ; } }; const getEventTypeColor = (type: CandidateEvent['eventType'] | string | undefined) => { if (!type) return 'bg-slate-50 text-slate-600 border-slate-200'; switch (type) { case 'call': return 'bg-blue-50 text-blue-600 border-blue-200'; case 'interview_1': case 'interview_2': case 'interview_3': return 'bg-amber-50 text-amber-600 border-amber-200'; case 'test_task': return 'bg-purple-50 text-purple-600 border-purple-200'; case 'offer': case 'offer_accepted': return 'bg-emerald-50 text-emerald-600 border-emerald-200'; case 'offer_rejected': case 'rejected': return 'bg-red-50 text-red-600 border-red-200'; case 'probation_start': case 'hired': return 'bg-green-50 text-green-600 border-green-200'; default: return 'bg-slate-50 text-slate-600 border-slate-200'; } }; const getResultColor = (result?: CandidateEvent['result']) => { switch (result) { case 'success': return 'bg-emerald-50 text-emerald-600'; case 'failed': return 'bg-red-50 text-red-600'; case 'cancelled': return 'bg-slate-50 text-slate-600'; default: return 'bg-amber-50 text-amber-600'; } }; const getResultLabel = (result?: CandidateEvent['result']) => { switch (result) { case 'success': return 'Успешно'; case 'failed': return 'Неудачно'; case 'cancelled': return 'Отменено'; default: return 'Ожидается'; } }; const sortedEvents = [...events].sort((a, b) => new Date(b.eventDate).getTime() - new Date(a.eventDate).getTime() ); return (

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

{candidate.stage === 'interview' && ( )}
{showBookRoomModal && ( setShowBookRoomModal(false)} onClose={() => setShowBookRoomModal(false)} /> )} {loading ? (
Загрузка событий...
) : sortedEvents.length === 0 ? (
Нет событий. Добавьте первое событие для кандидата.
) : (
{sortedEvents.map((event, index) => { let eventDate: Date | null = null; let isPast = false; let dateString = 'Дата не указана'; // Отладочная информация if (!event.eventType) { console.warn('Событие без типа:', event); } try { if (event.eventDate) { eventDate = new Date(event.eventDate); if (!isNaN(eventDate.getTime())) { isPast = eventDate < new Date(); // Форматируем дату и время отдельно для лучшей читаемости const datePart = eventDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); const timePart = eventDate.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); dateString = `${datePart} в ${timePart}`; } else { console.warn('Невалидная дата события:', event.eventDate); } } } catch (e) { console.error('Ошибка парсинга даты:', event.eventDate, e); } return (
{getEventTypeIcon(event.eventType)}
{getEventTypeLabel(event.eventType || event.event_type || 'other')}
{event.result && ( {getResultLabel(event.result)} )}
{dateString} {!isPast && eventDate && !isNaN(eventDate.getTime()) && ( (Запланировано) )}
{event.interviewer && (
Проводит: {event.interviewer}
)} {event.location && (
{event.location}
)} {event.durationMinutes && (
Длительность: {event.durationMinutes} мин.
)} {event.notes && (

{event.notes}

)}
); })}
)} {/* Модальное окно создания/редактирования события */} {isFormOpen && ( { setIsFormOpen(false); setEditingEvent(null); }} onSave={async () => { await fetchEvents(); setIsFormOpen(false); setEditingEvent(null); onEventAdded?.(); onEventUpdated?.(); }} /> )}
); }; // Компонент формы для создания/редактирования события interface CandidateEventFormModalProps { candidate: Candidate; event?: CandidateEvent | null; onClose: () => void; onSave: () => void; } const CandidateEventFormModal: React.FC = ({ candidate, event, onClose, onSave }) => { const isEditMode = !!event; const [formData, setFormData] = useState({ eventType: event?.eventType || 'call', eventDate: event?.eventDate ? new Date(event.eventDate).toISOString().split('T')[0] : new Date().toISOString().split('T')[0], eventTime: event?.eventDate ? new Date(event.eventDate).toTimeString().slice(0, 5) : '10:00', notes: event?.notes || '', result: event?.result || 'pending', interviewer: event?.interviewer || '', location: event?.location || '', durationMinutes: event?.durationMinutes?.toString() || '', }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!formData.eventDate || !formData.eventTime) { alert('Заполните дату и время события'); return; } try { const eventDateTime = new Date(`${formData.eventDate}T${formData.eventTime}`); const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; const apiUrl = (import.meta.env.DEV || !apiBaseUrl) ? `/api/candidates/${candidate.id}/events${isEditMode ? `/${event.id}` : ''}` : `${apiBaseUrl}/candidates/${candidate.id}/events${isEditMode ? `/${event.id}` : ''}`; const method = isEditMode ? 'PUT' : 'POST'; const body = { eventType: formData.eventType, eventDate: eventDateTime.toISOString(), notes: formData.notes?.trim() || null, result: formData.result, interviewer: formData.interviewer?.trim() || null, location: formData.location?.trim() || null, durationMinutes: formData.durationMinutes ? parseInt(formData.durationMinutes) : 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 errorData = await response.json(); errorMessage = errorData.error || errorData.details || errorMessage; } catch (e) { const text = await response.text().catch(() => ''); if (text) errorMessage = text; } throw new Error(errorMessage); } const savedEvent = await response.json(); onSave(); } catch (error) { console.error('Error saving event:', error); const errorMessage = error instanceof Error ? error.message : 'Ошибка при сохранении события'; alert(errorMessage); } }; return (
e.stopPropagation()} >

{isEditMode ? 'Редактирование события' : 'Создание события'}

setFormData({ ...formData, eventDate: 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" required />
setFormData({ ...formData, eventTime: 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" required />
{(formData.eventType.includes('interview') || formData.eventType === 'test_task') && ( <>
setFormData({ ...formData, interviewer: 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" />
setFormData({ ...formData, location: 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" />
setFormData({ ...formData, durationMinutes: e.target.value })} placeholder="60" className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none" />
)}