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

621 lines
32 KiB
TypeScript
Executable File
Raw Permalink 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 { 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>
);
};