Files
mkd/components/hr/CandidateEventsTimeline.tsx

621 lines
32 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
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<CandidateEventsTimelineProps> = ({
candidate,
onEventAdded,
onEventUpdated,
onCandidateUpdated
}) => {
const [events, setEvents] = useState<CandidateEvent[]>(candidate.events || []);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<CandidateEvent | null>(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<string, string> = {
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 <Clock className="w-4 h-4" />;
switch (type) {
case 'call': return <Phone className="w-4 h-4" />;
case 'interview_1':
case 'interview_2':
case 'interview_3': return <Calendar className="w-4 h-4" />;
case 'test_task': return <FileText className="w-4 h-4" />;
case 'offer':
case 'offer_accepted': return <CheckCircle className="w-4 h-4" />;
case 'offer_rejected':
case 'rejected': return <X className="w-4 h-4" />;
case 'probation_start':
case 'hired': return <User className="w-4 h-4" />;
default: return <Clock className="w-4 h-4" />;
}
};
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 (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Clock className="w-4 h-4"/> История событий
</h4>
<div className="flex items-center gap-2">
{candidate.stage === 'interview' && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTimeout(() => setShowBookRoomModal(true), 0);
}}
className="px-3 py-1.5 bg-amber-500 text-white rounded-xl text-[10px] font-black uppercase flex items-center gap-1.5 hover:bg-amber-600 transition-all"
title="Забронировать переговорную для собеседования"
>
<Building2 className="w-3.5 h-3.5"/> Забронировать переговорную
</button>
)}
<button
onClick={handleAddEvent}
className="px-3 py-1.5 bg-primary-600 text-white rounded-xl text-[10px] font-black uppercase flex items-center gap-1.5 hover:bg-primary-700 transition-all"
>
<Plus className="w-3.5 h-3.5"/> Добавить событие
</button>
</div>
</div>
{showBookRoomModal && (
<BookMeetingRoomModal
purpose={`Собеседование: ${candidate.name}`}
onBooked={() => setShowBookRoomModal(false)}
onClose={() => setShowBookRoomModal(false)}
/>
)}
{loading ? (
<div className="text-center py-8 text-slate-400 text-sm">Загрузка событий...</div>
) : sortedEvents.length === 0 ? (
<div className="text-center py-8 text-slate-400 text-sm bg-slate-50 rounded-xl border border-slate-100">
Нет событий. Добавьте первое событие для кандидата.
</div>
) : (
<div className="space-y-3">
{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 (
<div
key={event.id}
className={`bg-white p-4 rounded-xl border-2 ${getEventTypeColor(event.eventType)} relative`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<div className={`p-2 rounded-lg ${getEventTypeColor(event.eventType)} shrink-0`}>
{getEventTypeIcon(event.eventType)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<h5 className="font-black text-slate-800 text-base leading-tight min-w-[150px]">
{getEventTypeLabel(event.eventType || event.event_type || 'other')}
</h5>
{event.result && (
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${getResultColor(event.result)}`}>
{getResultLabel(event.result)}
</span>
)}
</div>
</div>
<div className="space-y-1.5 text-xs text-slate-600">
<div className="flex items-center gap-2 flex-wrap">
<Calendar className="w-3.5 h-3.5 text-slate-400 shrink-0"/>
<span className="font-medium">{dateString}</span>
{!isPast && eventDate && !isNaN(eventDate.getTime()) && (
<span className="text-amber-600 font-bold text-[10px]">(Запланировано)</span>
)}
</div>
{event.interviewer && (
<div className="flex items-center gap-2">
<User className="w-3.5 h-3.5 text-slate-400"/>
<span>Проводит: {event.interviewer}</span>
</div>
)}
{event.location && (
<div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-slate-400"/>
<span>{event.location}</span>
</div>
)}
{event.durationMinutes && (
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-slate-400"/>
<span>Длительность: {event.durationMinutes} мин.</span>
</div>
)}
{event.notes && (
<div className="mt-2 p-2 bg-slate-50 rounded-lg border border-slate-100">
<p className="text-xs text-slate-600 whitespace-pre-wrap">{event.notes}</p>
</div>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => handleEditEvent(event)}
className="p-1.5 text-slate-400 hover:text-primary-600 transition-colors"
title="Редактировать"
>
<Edit className="w-4 h-4"/>
</button>
<button
onClick={() => handleDeleteEvent(event)}
className="p-1.5 text-slate-400 hover:text-red-600 transition-colors"
title="Удалить"
>
<Trash2 className="w-4 h-4"/>
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Модальное окно создания/редактирования события */}
{isFormOpen && (
<CandidateEventFormModal
candidate={candidate}
event={editingEvent}
onClose={() => {
setIsFormOpen(false);
setEditingEvent(null);
}}
onSave={async () => {
await fetchEvents();
setIsFormOpen(false);
setEditingEvent(null);
onEventAdded?.();
onEventUpdated?.();
}}
/>
)}
</div>
);
};
// Компонент формы для создания/редактирования события
interface CandidateEventFormModalProps {
candidate: Candidate;
event?: CandidateEvent | null;
onClose: () => void;
onSave: () => void;
}
const CandidateEventFormModal: React.FC<CandidateEventFormModalProps> = ({
candidate,
event,
onClose,
onSave
}) => {
const isEditMode = !!event;
const [formData, setFormData] = useState<EventFormData>({
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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
<div
className="bg-white rounded-2xl w-full max-w-2xl 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">
<h3 className="text-xl font-bold text-slate-800">
{isEditMode ? 'Редактирование события' : 'Создание события'}
</h3>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-500"/>
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Тип события *
</label>
<select
value={formData.eventType}
onChange={(e) => setFormData({ ...formData, eventType: e.target.value as CandidateEvent['eventType'] })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
required
>
<option value="call">Созвон</option>
<option value="interview_1">Первое собеседование</option>
<option value="interview_2">Второе собеседование</option>
<option value="interview_3">Третье собеседование</option>
<option value="test_task">Тестовое задание</option>
<option value="offer">Оффер</option>
<option value="offer_accepted">Оффер принят</option>
<option value="offer_rejected">Оффер отклонен</option>
<option value="probation_start">Начало испытательного срока</option>
<option value="hired">Трудоустроен</option>
<option value="rejected">Отклонен</option>
<option value="other">Другое</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Дата *
</label>
<input
type="date"
value={formData.eventDate}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Время *
</label>
<input
type="time"
value={formData.eventTime}
onChange={(e) => 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
/>
</div>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Результат
</label>
<select
value={formData.result}
onChange={(e) => setFormData({ ...formData, result: e.target.value as CandidateEvent['result'] })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="pending">Ожидается</option>
<option value="success">Успешно</option>
<option value="failed">Неудачно</option>
<option value="cancelled">Отменено</option>
</select>
</div>
{(formData.eventType.includes('interview') || formData.eventType === 'test_task') && (
<>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Кто проводит
</label>
<input
type="text"
value={formData.interviewer}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Место проведения
</label>
<input
type="text"
value={formData.location}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Длительность (минуты)
</label>
<input
type="number"
value={formData.durationMinutes}
onChange={(e) => 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"
/>
</div>
</>
)}
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Заметки
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Заметки о событии..."
rows={4}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="flex-1 py-3 bg-slate-50 text-slate-700 rounded-xl text-xs font-black uppercase hover:bg-slate-100 transition-all"
>
Отмена
</button>
<button
type="submit"
className="flex-1 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase shadow-lg shadow-primary-500/20 hover:bg-primary-700 active:scale-95 transition-all"
>
{isEditMode ? 'Сохранить' : 'Создать событие'}
</button>
</div>
</form>
</div>
</div>
);
};