621 lines
32 KiB
TypeScript
621 lines
32 KiB
TypeScript
|
|
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|