Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { Employee, TrainingProgram } from '../../types';
interface AssignTrainingModalProps {
employees: Employee[];
programs: TrainingProgram[];
onClose: () => void;
onAssign: (employeeId: string, programId: string, startDate?: string) => void;
}
export const AssignTrainingModal: React.FC<AssignTrainingModalProps> = ({
employees,
programs,
onClose,
onAssign
}) => {
const [selectedEmployee, setSelectedEmployee] = useState('');
const [selectedProgram, setSelectedProgram] = useState('');
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedEmployee || !selectedProgram) {
alert('Выберите сотрудника и программу обучения');
return;
}
onAssign(selectedEmployee, selectedProgram, startDate);
};
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-lg 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-2xl font-bold text-slate-800">Назначить обучение</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-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Сотрудник *
</label>
<select
value={selectedEmployee}
onChange={(e) => setSelectedEmployee(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
required
>
<option value="">Выберите сотрудника</option>
{employees.map(emp => (
<option key={emp.id} value={emp.id}>
{emp.name} - {emp.position}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Программа обучения *
</label>
<select
value={selectedProgram}
onChange={(e) => setSelectedProgram(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
required
>
<option value="">Выберите программу</option>
{programs.map(prog => (
<option key={prog.id} value={prog.id}>
{prog.title} ({prog.type === 'instruction' ? 'Инструктаж' :
prog.type === 'course' ? 'Курс' :
prog.type === 'certification' ? 'Сертификация' :
prog.type === 'exam' ? 'Экзамен' : 'Другое'})
</option>
))}
</select>
{programs.length === 0 && (
<p className="text-xs text-slate-400 mt-2">
Нет доступных программ. Создайте программу обучения сначала.
</p>
)}
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Дата начала
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-3 border border-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-50 transition-all"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-emerald-600 text-white rounded-xl font-bold hover:bg-emerald-700 transition-all"
disabled={!selectedEmployee || !selectedProgram || programs.length === 0}
>
Назначить
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,620 @@
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>
);
};

View File

@@ -0,0 +1,466 @@
import React, { useState, useEffect } from 'react';
import { Candidate, Vacancy } from '../../types';
import { X, User, Phone, Mail, FileText, Briefcase, Calendar, MessageSquare, DollarSign } from 'lucide-react';
import { CandidateEventsTimeline } from './CandidateEventsTimeline';
import { authFetch } from '../../services/apiClient';
interface CandidateFormModalProps {
candidate?: Candidate | null;
vacancyId?: string | null;
vacancies?: Vacancy[];
onClose: () => void;
onSave: (candidate: Candidate) => void;
}
export const CandidateFormModal: React.FC<CandidateFormModalProps> = ({
candidate,
vacancyId: initialVacancyId,
vacancies = [],
onClose,
onSave
}) => {
const isEditMode = !!candidate;
const [formData, setFormData] = useState({
name: candidate?.name || '',
position: candidate?.position || '',
vacancyId: candidate?.vacancyId || initialVacancyId || '',
stage: candidate?.stage || 'new' as 'new' | 'interview' | 'probation' | 'hired' | 'rejected',
phone: candidate?.phone || '',
email: candidate?.email || '',
resumeUrl: candidate?.resumeUrl || '',
coverLetter: candidate?.coverLetter || '',
interviewDate: candidate?.interviewDate ? new Date(candidate.interviewDate).toISOString().split('T')[0] : '',
interviewNotes: candidate?.interviewNotes || '',
offerSalary: candidate?.offerSalary?.toString() || '',
offerDate: candidate?.offerDate || '',
hiredDate: candidate?.hiredDate || '',
rejectedReason: candidate?.rejectedReason || '',
});
const [loadingVacancies, setLoadingVacancies] = useState(false);
const [availableVacancies, setAvailableVacancies] = useState<Vacancy[]>(vacancies);
// Загружаем вакансии если не переданы
useEffect(() => {
if (vacancies.length === 0) {
const fetchVacancies = async () => {
try {
setLoadingVacancies(true);
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();
setAvailableVacancies(data);
}
} catch (error) {
console.error('Error fetching vacancies:', error);
} finally {
setLoadingVacancies(false);
}
};
fetchVacancies();
}
}, [vacancies.length]);
// Обновляем позицию при выборе вакансии
useEffect(() => {
if (formData.vacancyId) {
const selectedVacancy = availableVacancies.find(v => v.id === formData.vacancyId);
if (selectedVacancy && !isEditMode) {
setFormData(prev => ({ ...prev, position: selectedVacancy.position }));
}
}
}, [formData.vacancyId, availableVacancies, isEditMode]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.position || !formData.phone) {
alert('Заполните обязательные поля: ФИО, позиция и телефон');
return;
}
try {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const apiUrl = (import.meta.env.DEV || !apiBaseUrl)
? `/api/candidates${isEditMode ? `/${candidate.id}` : ''}`
: `${apiBaseUrl}/candidates${isEditMode ? `/${candidate.id}` : ''}`;
const method = isEditMode ? 'PUT' : 'POST';
const body = {
...(isEditMode ? {} : { id: `cand-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` }),
name: formData.name.trim(),
position: formData.position.trim(),
vacancyId: formData.vacancyId && formData.vacancyId.trim() ? formData.vacancyId.trim() : null,
stage: formData.stage,
phone: formData.phone.trim(),
email: formData.email.trim() || null,
resumeUrl: formData.resumeUrl.trim() || null,
coverLetter: formData.coverLetter.trim() || null,
interviewDate: formData.interviewDate || null,
interviewNotes: formData.interviewNotes.trim() || null,
offerSalary: formData.offerSalary ? parseFloat(formData.offerSalary) : null,
offerDate: formData.offerDate || null,
hiredDate: formData.hiredDate || null,
rejectedReason: formData.rejectedReason.trim() || 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 text = await response.text();
if (text) {
try {
const errorData = JSON.parse(text);
errorMessage = errorData.error || errorData.message || text;
} catch {
errorMessage = text;
}
}
} catch (textError) {
console.error('Failed to read error response:', textError);
}
throw new Error(errorMessage);
}
const savedCandidate = await response.json();
onSave(savedCandidate);
} catch (error) {
console.error('Error saving candidate:', error);
let errorMessage = 'Ошибка при сохранении кандидата';
if (error instanceof Error) {
errorMessage = error.message;
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
errorMessage = 'Ошибка сети. Проверьте подключение к серверу.';
}
}
alert(errorMessage);
}
};
const getStageLabel = (stage: string) => {
switch (stage) {
case 'new': return 'Новый';
case 'interview': return 'Собеседование';
case 'probation': return 'Испытательный срок';
case 'hired': return 'Трудоустроен';
case 'rejected': return 'Отклонен';
default: return stage;
}
};
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-3xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<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-2xl 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 */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Основная информация */}
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<User className="w-4 h-4"/> Основная информация
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
ФИО *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: 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"
required
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<Phone className="w-3 h-3"/> Телефон *
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="+7 (999) 123-45-67"
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 flex items-center gap-1">
<Mail className="w-3 h-3"/> Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="ivanov@example.com"
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>
<select
value={formData.vacancyId || ''}
onChange={(e) => setFormData({ ...formData, vacancyId: 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"
disabled={loadingVacancies}
>
<option value="">Без вакансии</option>
{availableVacancies.map(vacancy => (
<option key={vacancy.id} value={vacancy.id}>
{vacancy.position} ({vacancy.department})
</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<Briefcase className="w-3 h-3"/> Позиция *
</label>
<input
type="text"
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: 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"
required
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Статус
</label>
<select
value={formData.stage}
onChange={(e) => setFormData({ ...formData, stage: e.target.value as any })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="new">Новый</option>
<option value="interview">Собеседование</option>
<option value="probation">Испытательный срок</option>
<option value="hired">Трудоустроен</option>
<option value="rejected">Отклонен</option>
</select>
</div>
</div>
</div>
{/* Документы и материалы */}
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<FileText className="w-4 h-4"/> Документы и материалы
</h4>
<div className="space-y-4">
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Ссылка на резюме
</label>
<input
type="url"
value={formData.resumeUrl}
onChange={(e) => setFormData({ ...formData, resumeUrl: e.target.value })}
placeholder="https://..."
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.coverLetter}
onChange={(e) => setFormData({ ...formData, coverLetter: e.target.value })}
placeholder="Текст сопроводительного письма..."
rows={3}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
</div>
{/* Собеседование */}
{(formData.stage === 'interview' || formData.stage === 'probation' || formData.stage === 'hired' || candidate?.interviewDate) && (
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Собеседование
</h4>
<div className="grid grid-cols-1 md: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.interviewDate}
onChange={(e) => setFormData({ ...formData, interviewDate: 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"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<MessageSquare className="w-3 h-3"/> Заметки с собеседования
</label>
<textarea
value={formData.interviewNotes}
onChange={(e) => setFormData({ ...formData, interviewNotes: 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>
)}
{/* Предложение */}
{(formData.stage === 'probation' || formData.stage === 'hired' || candidate?.offerDate) && (
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<DollarSign className="w-4 h-4"/> Предложение
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Предложенная зарплата
</label>
<input
type="number"
value={formData.offerSalary}
onChange={(e) => setFormData({ ...formData, offerSalary: e.target.value })}
placeholder="55000"
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="date"
value={formData.offerDate}
onChange={(e) => setFormData({ ...formData, offerDate: 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"
/>
</div>
</div>
</div>
)}
{/* Трудоустройство */}
{formData.stage === 'hired' && (
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<User className="w-4 h-4"/> Трудоустройство
</h4>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Дата трудоустройства
</label>
<input
type="date"
value={formData.hiredDate}
onChange={(e) => setFormData({ ...formData, hiredDate: 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"
/>
</div>
</div>
)}
{/* Отказ */}
{formData.stage === 'rejected' && (
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<X className="w-4 h-4"/> Отказ
</h4>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Причина отказа
</label>
<textarea
value={formData.rejectedReason}
onChange={(e) => setFormData({ ...formData, rejectedReason: e.target.value })}
placeholder="Причина отказа кандидату..."
rows={3}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
)}
{/* История событий (только в режиме редактирования) */}
{isEditMode && candidate && (
<div className="pt-6 border-t border-slate-200">
<CandidateEventsTimeline
candidate={{
...candidate,
id: candidate.id
}}
onEventAdded={() => {
// Можно обновить данные кандидата если нужно
}}
onCandidateUpdated={(updatedCandidate) => {
// Обновляем кандидата в форме
// Это обновит отображаемые данные
}}
/>
</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>
);
};

View 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>
);
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,413 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { MOCK_DISTRICTS } from '../../constants';
import { Employee, User, District } from '../../types';
import { Search, Filter, Phone, MessageCircle, MoreVertical, MapPin, Briefcase, Mail, User as UserIcon, Calendar, FileText, Eye, X, Plus } from 'lucide-react';
import { EmployeeCardModal } from './EmployeeCardModal';
import { backendApi, authFetch } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
interface EmployeeRegistryProps {
onEmployeeCreated?: () => void;
refreshTrigger?: string;
currentUser?: User; // Текущий пользователь для проверки прав доступа
onOpenCreateModal?: () => void; // Функция для открытия модального окна создания сотрудника
}
export const EmployeeRegistry: React.FC<EmployeeRegistryProps> = ({ onEmployeeCreated, refreshTrigger, currentUser, onOpenCreateModal }) => {
const CACHE_EMP = 'mkd_hr_employee_registry';
const CACHE_DIST = 'mkd_hr_districts';
const cachedEmp = readCache<Employee[]>(CACHE_EMP, []);
const cachedDist = readCache<District[]>(CACHE_DIST, []);
const [search, setSearch] = useState('');
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
const [employees, setEmployees] = useState<Employee[]>(cachedEmp);
const [districts, setDistricts] = useState<District[]>(cachedDist);
const [loading, setLoading] = useState(cachedEmp.length === 0);
// Фильтры
const [showFilters, setShowFilters] = useState(false);
const [statusFilter, setStatusFilter] = useState<('active' | 'vacation' | 'inactive')[]>(['active', 'vacation']); // По умолчанию скрываем неактивных
const [districtFilter, setDistrictFilter] = useState<string | null>(null);
const filtersRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchEmployees();
fetchDistricts();
}, [refreshTrigger]);
useEffect(() => {
const onRefresh = () => { fetchEmployees(); fetchDistricts(); };
window.addEventListener(REFRESH_EVENTS.employees, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.employees, onRefresh);
}, []);
useEffect(() => {
const interval = setInterval(() => { fetchEmployees(false); fetchDistricts(); }, 10 * 1000);
return () => clearInterval(interval);
}, []);
// Закрытие панели фильтров при клике вне её
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (filtersRef.current && !filtersRef.current.contains(event.target as Node)) {
setShowFilters(false);
}
};
if (showFilters) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showFilters]);
const fetchEmployees = async (showSpinner = true) => {
try {
if (showSpinner && cachedEmp.length === 0) setLoading(true);
const response = await authFetch('/api/employees');
if (response.ok) {
const data = await response.json();
setEmployees(data);
saveCache(CACHE_EMP, data);
} else setEmployees([]);
} catch (error) {
console.error('Error fetching employees:', error);
setEmployees([]);
} finally {
setLoading(false);
}
};
const fetchDistricts = async () => {
try {
const data = await backendApi.getDistricts();
setDistricts(data);
saveCache(CACHE_DIST, data);
} catch (error) {
console.warn('Failed to fetch districts from API, using MOCK_DISTRICTS:', error);
setDistricts(MOCK_DISTRICTS);
}
};
const handleEmployeeUpdate = async (updatedEmployee: Employee) => {
try {
const response = await authFetch(`/api/employees/${updatedEmployee.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedEmployee)
});
if (response.ok) {
window.dispatchEvent(new CustomEvent('mkd-employees-changed'));
setEmployees(employees.map(emp =>
emp.id === updatedEmployee.id ? updatedEmployee : emp
));
setSelectedEmployee(updatedEmployee);
} else {
alert('Ошибка при обновлении сотрудника');
}
} catch (error) {
console.error('Error updating employee:', error);
alert('Ошибка при обновлении сотрудника');
}
};
const filtered = employees.filter(e => {
// Поиск по имени и должности
const matchesSearch = e.name.toLowerCase().includes(search.toLowerCase()) ||
e.position.toLowerCase().includes(search.toLowerCase());
// Фильтр по статусу
const matchesStatus = statusFilter.includes(e.status);
// Фильтр по участку
const matchesDistrict = !districtFilter || e.assignedDistrictId === districtFilter;
return matchesSearch && matchesStatus && matchesDistrict;
});
const getDistrictName = (districtId: string) => {
const district = districts.find(d => d.id === districtId) || MOCK_DISTRICTS.find(d => d.id === districtId);
return district?.name || 'Не указан';
};
const toggleStatusFilter = (status: 'active' | 'vacation' | 'inactive') => {
setStatusFilter(prev =>
prev.includes(status)
? prev.filter(s => s !== status)
: [...prev, status]
);
};
const getActiveFiltersCount = () => {
let count = 0;
if (statusFilter.length !== 3) count++; // Если не все статусы выбраны
if (districtFilter) count++;
return count;
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="text-slate-400">Загрузка сотрудников...</div>
</div>
);
}
return (
<div className="space-y-4 animate-fade-in">
{selectedEmployee && (
<EmployeeCardModal
employee={selectedEmployee}
onClose={() => setSelectedEmployee(null)}
onUpdate={handleEmployeeUpdate}
currentUser={currentUser}
/>
)}
<div className="flex gap-4 items-center">
<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={search}
onChange={e => setSearch(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>
{onOpenCreateModal && (
<button
className="bg-primary-600 text-white p-2.5 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 px-4 text-xs font-black uppercase tracking-wider active:scale-95 transition-all whitespace-nowrap"
onClick={onOpenCreateModal}
>
<Plus className="w-4 h-4" /> Добавить сотрудника
</button>
)}
<div className="relative" ref={filtersRef}>
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-3 bg-white border border-slate-200 rounded-2xl text-slate-500 hover:bg-slate-50 transition-all shadow-sm relative ${showFilters ? 'bg-primary-50 border-primary-300 text-primary-600' : ''}`}
>
<Filter className="w-5 h-5"/>
{getActiveFiltersCount() > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-primary-600 text-white text-[10px] font-black rounded-full flex items-center justify-center">
{getActiveFiltersCount()}
</span>
)}
</button>
{showFilters && (
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-2xl shadow-lg z-50 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-black text-slate-800 text-sm">Фильтры</h3>
<button
onClick={() => setShowFilters(false)}
className="p-1 text-slate-400 hover:text-slate-600"
>
<X className="w-4 h-4"/>
</button>
</div>
{/* Фильтр по статусу */}
<div className="mb-4">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2 block">
Статус
</label>
<div className="space-y-2">
{[
{ value: 'active' as const, label: 'В строю', color: 'emerald' },
{ value: 'vacation' as const, label: 'В отпуске', color: 'amber' },
{ value: 'inactive' as const, label: 'Неактивен (уволен)', color: 'red' }
].map(status => (
<label
key={status.value}
className="flex items-center gap-2 cursor-pointer p-2 rounded-xl hover:bg-slate-50"
>
<input
type="checkbox"
checked={statusFilter.includes(status.value)}
onChange={() => toggleStatusFilter(status.value)}
className="w-4 h-4 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<span className={`text-xs font-bold ${statusFilter.includes(status.value) ? 'text-slate-800' : 'text-slate-400'}`}>
{status.label}
</span>
</label>
))}
</div>
</div>
{/* Фильтр по участку */}
<div>
<label className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2 block">
Участок
</label>
<select
value={districtFilter || ''}
onChange={(e) => setDistrictFilter(e.target.value || null)}
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-300"
>
<option value="">Все участки</option>
{districts.map(district => (
<option key={district.id} value={district.id}>
{district.name}
</option>
))}
</select>
</div>
{/* Кнопка сброса фильтров */}
{(statusFilter.length !== 3 || districtFilter) && (
<button
onClick={() => {
setStatusFilter(['active', 'vacation']);
setDistrictFilter(null);
}}
className="mt-4 w-full py-2 text-xs font-black text-slate-500 hover:text-slate-700 uppercase tracking-wider"
>
Сбросить фильтры
</button>
)}
</div>
)}
</div>
</div>
{/* Активные фильтры (чипсы) */}
{(statusFilter.length !== 3 || districtFilter) && (
<div className="flex flex-wrap gap-2">
{statusFilter.length !== 3 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-primary-50 text-primary-700 rounded-full text-[10px] font-black uppercase">
<span>
{statusFilter.length === 1
? statusFilter[0] === 'active' ? 'В строю'
: statusFilter[0] === 'vacation' ? 'В отпуске'
: 'Неактивен'
: `${statusFilter.length} статуса`
}
</span>
<button
onClick={() => setStatusFilter(['active', 'vacation', 'inactive'])}
className="hover:text-primary-900"
>
<X className="w-3 h-3"/>
</button>
</div>
)}
{districtFilter && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-primary-50 text-primary-700 rounded-full text-[10px] font-black uppercase">
<span>{getDistrictName(districtFilter)}</span>
<button
onClick={() => setDistrictFilter(null)}
className="hover:text-primary-900"
>
<X className="w-3 h-3"/>
</button>
</div>
)}
</div>
)}
{filtered.length === 0 ? (
<div className="text-center py-20 text-slate-400">
<p>Сотрудники не найдены</p>
{search && <p className="text-sm mt-2">Попробуйте изменить поисковый запрос</p>}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map(emp => (
<div
key={emp.id}
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm hover:shadow-md hover:border-primary-300 transition-all group cursor-pointer"
onClick={() => setSelectedEmployee(emp)}
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-4">
{emp.photoUrl ? (
<img
src={emp.photoUrl.startsWith('http')
? emp.photoUrl
: `${import.meta.env.VITE_API_BASE_URL?.replace('/api', '') || 'http://localhost:4000'}${emp.photoUrl}`}
alt={emp.name}
className="w-14 h-14 rounded-2xl object-cover border-2 border-slate-200"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = document.createElement('div');
fallback.className = 'w-14 h-14 rounded-2xl bg-slate-100 flex items-center justify-center text-xl font-black text-slate-400 group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors';
fallback.textContent = emp.name.split(' ').map(n => n[0]).join('');
parent.appendChild(fallback);
}
}}
/>
) : (
<div className="w-14 h-14 rounded-2xl bg-slate-100 flex items-center justify-center text-xl font-black text-slate-400 group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors">
{emp.name.split(' ').map(n => n[0]).join('')}
</div>
)}
<div>
<h4 className="font-black text-slate-800 text-base leading-tight group-hover:text-primary-600 transition-colors">{emp.name}</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider mt-1">{emp.position}</p>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className={`text-[9px] font-black px-2 py-1 rounded-full uppercase tracking-tighter ${emp.status === 'active' ? 'bg-emerald-50 text-emerald-600' : emp.status === 'vacation' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-red-600'}`}>
{emp.status === 'active' ? 'В строю' : emp.status === 'vacation' ? 'В отпуске' : 'Неактивен'}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedEmployee(emp);
}}
className="p-1 text-slate-300 hover:text-slate-600"
>
<Eye className="w-4 h-4"/>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-slate-400"/>
<span className="text-[10px] font-bold text-slate-600 uppercase truncate">{getDistrictName(emp.assignedDistrictId)}</span>
</div>
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center gap-2">
<Briefcase className="w-3.5 h-3.5 text-slate-400"/>
<span className="text-[10px] font-bold text-slate-600 uppercase">Оклад: {emp.salary.toLocaleString()}</span>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-slate-100">
<a
href={`tel:${emp.phone}`}
onClick={(e) => e.stopPropagation()}
className="flex-1 py-2.5 bg-slate-50 text-slate-700 rounded-xl text-[10px] font-black uppercase flex items-center justify-center gap-2 hover:bg-slate-100 transition-all"
>
<Phone className="w-3.5 h-3.5"/> Позвонить
</a>
{emp.messengerLogins && emp.messengerLogins.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
// Здесь можно добавить логику открытия мессенджера
}}
className="flex-1 py-2.5 bg-primary-600 text-white rounded-xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 active:scale-95 transition-all"
>
<MessageCircle className="w-3.5 h-3.5"/> Чат
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};

445
components/hr/HRSummary.tsx Executable file
View File

@@ -0,0 +1,445 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
UsersRound,
UserPlus,
Gift,
TrendingDown,
GraduationCap,
Plane,
FileWarning,
Calendar,
ClipboardList
} from 'lucide-react';
import { authFetch } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
const CACHE_KEY = 'mkd_hr_summary_cache';
interface HRSummaryData {
totalStaff: number;
vacancyCount: number;
activeCandidates: number;
turnoverRate: number;
onTraining: number;
staffingByDistrict: { districtId: string; districtName: string; current: number; total: number }[];
events: {
birthdays: { employeeId: string; name: string; initials: string; date: string; label: string }[];
vacationReturns: { employeeId: string; name: string; initials: string; date: string; label: string }[];
};
mostFrequentVacation: { employeeId: string; name: string; initials: string; count: number }[];
mostFrequentAbsences: {
employeeId: string;
name: string;
initials: string;
dayOff: number;
absence: number;
late: number;
earlyLeave: number;
sick: number;
total: number;
}[];
}
interface Props {
onNavigate: (tab: any) => void;
}
const defaultSummary: HRSummaryData = {
totalStaff: 0,
vacancyCount: 0,
activeCandidates: 0,
turnoverRate: 0,
onTraining: 0,
staffingByDistrict: [],
events: { birthdays: [], vacationReturns: [] },
mostFrequentVacation: [],
mostFrequentAbsences: []
};
export const HRSummary: React.FC<Props> = ({ onNavigate }) => {
const cached = readCache<HRSummaryData | null>(CACHE_KEY, null);
const [summary, setSummary] = useState<HRSummaryData>(cached || defaultSummary);
const [loading, setLoading] = useState(!cached);
const load = useCallback(async (showSpinner = true) => {
if (showSpinner && !cached) setLoading(true);
try {
const response = await authFetch('/api/hr/summary');
if (response.ok) {
const data = await response.json();
setSummary(data);
saveCache(CACHE_KEY, data);
} else setSummary(defaultSummary);
} catch (error) {
console.error('Error fetching HR summary:', error);
setSummary(defaultSummary);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, []);
useEffect(() => {
const onRefresh = () => load(false);
window.addEventListener(REFRESH_EVENTS.employees, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.employees, onRefresh);
}, [load]);
useEffect(() => {
const interval = setInterval(() => load(false), 60 * 1000);
return () => clearInterval(interval);
}, [load]);
const [holidaysRu, setHolidaysRu] = useState<{ date: string; name: string }[]>([]);
const [holidayYear, setHolidayYear] = useState(() => new Date().getFullYear());
useEffect(() => {
authFetch(`/api/holidays/ru?year=${holidayYear}`)
.then((r) => r.json())
.then((data) => setHolidaysRu(data.holidays || []))
.catch(() => setHolidaysRu([]));
}, [holidayYear]);
const eventsList = [
...summary.events.birthdays.map((e) => ({ ...e, type: 'birthday' as const })),
...summary.events.vacationReturns.map((e) => ({ ...e, type: 'vacation' as const }))
].slice(0, 10);
const sickAndDayOff = [...summary.mostFrequentAbsences]
.filter((e) => e.dayOff > 0 || e.sick > 0)
.sort((a, b) => b.dayOff + b.sick - (a.dayOff + a.sick))
.slice(0, 10);
const disciplinary = [...summary.mostFrequentAbsences]
.filter((e) => e.absence > 0 || e.late > 0 || e.earlyLeave > 0)
.sort((a, b) => b.absence + b.late + b.earlyLeave - (a.absence + a.late + a.earlyLeave))
.slice(0, 10);
return (
<div className="space-y-6 animate-fade-in">
{loading && (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && (
<>
{/* KPI Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={UsersRound}
label="Всего в штате"
value={summary.totalStaff}
color="text-primary-600"
bg="bg-primary-50"
onClick={() => onNavigate('employees')}
/>
<StatCard
icon={UserPlus}
label="Активный найм"
value={summary.activeCandidates}
subValue={`${summary.vacancyCount} вак.`}
color="text-violet-600"
bg="bg-violet-50"
onClick={() => onNavigate('hiring')}
/>
<StatCard
icon={TrendingDown}
label="Текучесть (год)"
value={`${summary.turnoverRate}%`}
color="text-emerald-600"
bg="bg-emerald-50"
/>
<StatCard
icon={GraduationCap}
label="На обучение"
value={summary.onTraining}
color="text-amber-600"
bg="bg-amber-50"
onClick={() => onNavigate('safety')}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Events Widget */}
<div className="max-w-md bg-slate-900 rounded-[2.5rem] p-6 text-white shadow-xl relative overflow-hidden flex flex-col">
<Gift className="absolute -top-4 -right-4 w-32 h-32 opacity-10 rotate-12" />
<div className="relative z-10 mb-8">
<h4 className="text-xs font-black text-amber-400 uppercase tracking-widest mb-4">События недели</h4>
<div className="space-y-4">
{eventsList.length === 0 && (
<p className="text-[10px] text-slate-400">Нет предстоящих событий</p>
)}
{eventsList.map((e) => {
const eventSubtitle =
e.type === 'birthday' && (e.label === 'Сегодня' || e.label === 'Завтра')
? `День рождения • ${e.label}`
: e.type === 'birthday'
? `День рождения • ${e.label.replace(/^День рождения •\s*/i, '')}`
: e.label;
return (
<div key={`${e.type}-${e.employeeId}`} className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center font-bold border border-white/10 text-xs">
{e.initials}
</div>
<div>
<p className="text-sm font-bold leading-none">{e.name}</p>
<p className="text-[10px] text-slate-400 mt-1">{eventSubtitle}</p>
</div>
</div>
);
})}
</div>
</div>
<button
onClick={() => onNavigate('calendar')}
className="mt-auto w-full py-3 bg-white/10 hover:bg-white/20 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-colors border border-white/10"
>
Поздравить всех
</button>
</div>
{/* Календарь праздников РФ */}
<HolidayCalendar holidays={holidaysRu} currentYear={holidayYear} onYearChange={setHolidayYear} />
</div>
{/* Самые частые в отпуске / больничные и отгулы / дисциплинарные нарушения */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex items-center gap-2">
<Plane className="w-4 h-4 text-primary-500" />
<h3 className="font-black text-[10px] text-slate-500 uppercase tracking-widest">
Самые частые в отпуске
</h3>
</div>
<div className="p-6">
{summary.mostFrequentVacation.length === 0 && (
<p className="text-xs text-slate-500">Нет данных</p>
)}
<ul className="space-y-3">
{summary.mostFrequentVacation.map((e) => (
<li key={e.employeeId} className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-primary-50 flex items-center justify-center font-bold text-primary-600 text-xs">
{e.initials}
</div>
<span className="text-sm font-bold text-slate-800">{e.name}</span>
</div>
<span className="text-[10px] font-black text-slate-400 uppercase bg-slate-100 px-2 py-1 rounded-full">
{e.count} отпуск{e.count === 1 ? '' : e.count < 5 ? 'а' : 'ов'}
</span>
</li>
))}
</ul>
</div>
</div>
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-blue-500" />
<h3 className="font-black text-[10px] text-slate-500 uppercase tracking-widest">
Больничные и отгулы
</h3>
</div>
<div className="p-6">
{sickAndDayOff.length === 0 && (
<p className="text-xs text-slate-500">Нет данных</p>
)}
<ul className="space-y-3">
{sickAndDayOff.map((e) => (
<li key={e.employeeId} className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-full bg-blue-50 flex items-center justify-center font-bold text-blue-600 text-xs">
{e.initials}
</div>
<span className="text-sm font-bold text-slate-800">{e.name}</span>
</div>
<div className="flex flex-wrap gap-1 text-[9px] font-bold">
{e.dayOff > 0 && (
<span className="bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded">отгулы: {e.dayOff}</span>
)}
{e.sick > 0 && (
<span className="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">больн.: {e.sick}</span>
)}
</div>
</li>
))}
</ul>
</div>
</div>
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex items-center gap-2">
<FileWarning className="w-4 h-4 text-amber-500" />
<h3 className="font-black text-[10px] text-slate-500 uppercase tracking-widest">
Дисциплинарные нарушения
</h3>
</div>
<div className="p-6">
{disciplinary.length === 0 && (
<p className="text-xs text-slate-500">Нет данных</p>
)}
<ul className="space-y-3">
{disciplinary.map((e) => (
<li key={e.employeeId} className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-full bg-amber-50 flex items-center justify-center font-bold text-amber-600 text-xs">
{e.initials}
</div>
<span className="text-sm font-bold text-slate-800">{e.name}</span>
</div>
<div className="flex flex-wrap gap-1 text-[9px] font-bold">
{e.absence > 0 && (
<span className="bg-red-50 text-red-600 px-1.5 py-0.5 rounded">прогулы: {e.absence}</span>
)}
{e.late > 0 && (
<span className="bg-orange-50 text-orange-600 px-1.5 py-0.5 rounded">опозд.: {e.late}</span>
)}
{e.earlyLeave > 0 && (
<span className="bg-amber-50 text-amber-600 px-1.5 py-0.5 rounded">уходы: {e.earlyLeave}</span>
)}
</div>
</li>
))}
</ul>
</div>
</div>
</div>
</>
)}
</div>
);
};
const StatCard = ({ icon: Icon, label, value, subValue, color, bg, onClick }: any) => (
<div
onClick={onClick}
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm cursor-pointer hover:border-primary-400 transition-all hover:shadow-md active:scale-95"
>
<div className="flex justify-between items-start mb-3">
<div className={`p-2.5 ${bg} ${color} rounded-2xl`}>
<Icon className="w-5 h-5" />
</div>
{subValue != null && subValue !== '' && (
<span className="text-[9px] font-black text-primary-500 bg-primary-50 px-2 py-1 rounded-full uppercase">
{subValue}
</span>
)}
</div>
<p className="text-2xl font-black text-slate-800 leading-none">{value}</p>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-2">{label}</p>
</div>
);
const MONTH_NAMES = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const HolidayCalendar: React.FC<{
holidays: { date: string; name: string }[];
currentYear: number;
onYearChange: (y: number) => void;
}> = ({ holidays, currentYear, onYearChange }) => {
const today = useMemo(() => new Date(), []);
const [viewMonth, setViewMonth] = useState(today.getMonth());
const [viewYear, setViewYear] = useState(currentYear);
const displayYear = viewYear;
const holidaysByDate = useMemo(() => {
const map: Record<string, string> = {};
holidays.forEach((h) => { map[h.date] = h.name; });
return map;
}, [holidays]);
if (viewYear !== currentYear) setViewYear(currentYear);
const firstDay = new Date(displayYear, viewMonth, 1);
const lastDay = new Date(displayYear, viewMonth + 1, 0);
const startWeekday = firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1;
const daysInMonth = lastDay.getDate();
const cells: (number | null)[] = [];
for (let i = 0; i < startWeekday; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
const rows: (number | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));
const toDateStr = (day: number) =>
`${displayYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isToday = (day: number) =>
today.getDate() === day && today.getMonth() === viewMonth && today.getFullYear() === displayYear;
const goPrev = () => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear((y) => y - 1);
onYearChange(displayYear - 1);
} else setViewMonth((m) => m - 1);
};
const goNext = () => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear((y) => y + 1);
onYearChange(displayYear + 1);
} else setViewMonth((m) => m + 1);
};
return (
<div className="bg-white rounded-[2rem] border border-slate-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between gap-2">
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary-500" /> Праздники РФ
</h4>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goPrev}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 text-xs font-bold"
>
</button>
<span className="text-xs font-bold text-slate-700 min-w-[100px] text-center">
{MONTH_NAMES[viewMonth]} {displayYear}
</span>
<button
type="button"
onClick={goNext}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 text-xs font-bold"
>
</button>
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-7 gap-0.5 text-center">
{WEEKDAYS.map((wd) => (
<div key={wd} className="text-[9px] font-black text-slate-400 py-1">
{wd}
</div>
))}
{rows.flatMap((row, ri) =>
row.map((day, di) => {
if (day === null) {
return <div key={`e-${ri}-${di}`} className="aspect-square" />;
}
const dateStr = toDateStr(day);
const holidayName = holidaysByDate[dateStr];
const todayCell = isToday(day);
return (
<div
key={dateStr}
title={holidayName || undefined}
className={`aspect-square flex items-center justify-center text-[11px] font-bold rounded-lg ${todayCell ? 'bg-primary-100 text-primary-700 ring-1 ring-primary-300' : holidayName ? 'bg-amber-100 text-amber-800 cursor-help' : 'text-slate-600 hover:bg-slate-50'}`}
>
{day}
</div>
);
})
)}
</div>
<p className="text-[9px] text-slate-400 mt-3 text-center">
Наведите на отмеченную дату подсказка с названием праздника
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { MOCK_CANDIDATES } from '../../constants';
import { Search, UserPlus, Phone, FileText, ChevronRight, MoreHorizontal } from 'lucide-react';
const STAGES = [
{ id: 'new', label: 'Новые', color: 'bg-slate-100 text-slate-500', border: 'border-slate-200' },
{ id: 'interview', label: 'Собеседование', color: 'bg-primary-50 text-primary-600', border: 'border-primary-200' },
{ id: 'offer', label: 'Оффер', color: 'bg-emerald-50 text-emerald-600', border: 'border-emerald-200' },
];
export const HiringPipeline: React.FC = () => {
return (
<div className="space-y-6 animate-fade-in">
{/* Stage Stats */}
<div className="flex justify-between items-center bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm overflow-hidden relative">
<div className="absolute top-0 right-0 p-8 opacity-5"><UserPlus className="w-24 h-24"/></div>
{STAGES.map((stage, idx) => {
const count = MOCK_CANDIDATES.filter(c => c.stage === stage.id).length;
return (
<React.Fragment key={stage.id}>
<div className="text-center px-4">
<p className="text-2xl font-black text-slate-800 leading-none">{count}</p>
<p className={`text-[9px] font-black uppercase mt-1.5 ${stage.color.split(' ')[1]}`}>{stage.label}</p>
</div>
{idx < STAGES.length - 1 && <div className="h-8 w-px bg-slate-100"/>}
</React.Fragment>
);
})}
</div>
{/* Candidate Cards */}
<div className="space-y-4">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Активные соискатели</h3>
{MOCK_CANDIDATES.map(cand => (
<div key={cand.id} className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm group hover:border-primary-300 transition-all flex flex-col md:flex-row justify-between gap-6">
<div className="flex items-center gap-4">
<div className={`p-4 rounded-2xl ${STAGES.find(s => s.id === cand.stage)?.color.split(' ')[0]}`}>
<UserPlus className={`w-7 h-7 ${STAGES.find(s => s.id === cand.stage)?.color.split(' ')[1]}`}/>
</div>
<div>
<div className="flex items-center gap-2 mb-0.5">
<h4 className="font-black text-slate-800 text-base leading-tight">{cand.name}</h4>
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded-full uppercase ${STAGES.find(s => s.id === cand.stage)?.color}`}>
{STAGES.find(s => s.id === cand.stage)?.label}
</span>
</div>
<p className="text-[10px] text-slate-400 font-bold uppercase">{cand.position} Тел: {cand.phone}</p>
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-3 border-t md:border-t-0 border-slate-100 pt-4 md:pt-0">
<button className="p-2.5 bg-slate-50 text-slate-500 rounded-xl hover:bg-slate-100 transition-colors"><FileText className="w-5 h-5"/></button>
<button className="p-2.5 bg-slate-50 text-slate-500 rounded-xl hover:bg-slate-100 transition-colors"><Phone className="w-5 h-5"/></button>
<div className="h-10 w-px bg-slate-100 mx-1 hidden md:block" />
<button className="bg-slate-900 text-white px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg active:scale-95 transition-all">
{cand.stage === 'new' ? 'На собеседование' : cand.stage === 'interview' ? 'Сделать оффер' : 'Принять в штат'}
</button>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,357 @@
import React, { useState, useEffect } from 'react';
import {
Users,
ChevronDown,
ChevronRight,
User,
Phone,
Loader2,
AlertCircle,
ZoomIn,
ZoomOut,
Maximize2
} from 'lucide-react';
import { apiClient } from '../../services/apiClient';
interface OrgEmployee {
id: string;
name: string;
position: string;
phone: string;
status: 'active' | 'vacation' | 'inactive';
photoUrl?: string;
managerId?: string;
subordinates: OrgEmployee[];
subordinateCount?: number;
}
export const OrganizationalStructure: React.FC = () => {
const [structure, setStructure] = useState<OrgEmployee[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [zoom, setZoom] = useState(100);
const [selectedEmployee, setSelectedEmployee] = useState<OrgEmployee | null>(null);
useEffect(() => {
loadStructure();
}, []);
const loadStructure = async () => {
try {
setLoading(true);
setError(null);
const data = await apiClient.get<OrgEmployee[]>('/employees/organizational-structure');
setStructure(data);
// По умолчанию разворачиваем все узлы
const allIds = new Set<string>();
const collectIds = (employees: OrgEmployee[]) => {
employees.forEach(emp => {
allIds.add(emp.id);
if (emp.subordinates.length > 0) {
collectIds(emp.subordinates);
}
});
};
collectIds(data);
setExpandedNodes(allIds);
} catch (err: any) {
console.error('Error loading organizational structure:', err);
setError(err.message || 'Не удалось загрузить организационную структуру');
} finally {
setLoading(false);
}
};
const toggleNode = (id: string) => {
const newExpanded = new Set(expandedNodes);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedNodes(newExpanded);
};
const expandAll = () => {
const allIds = new Set<string>();
const collectIds = (employees: OrgEmployee[]) => {
employees.forEach(emp => {
allIds.add(emp.id);
if (emp.subordinates.length > 0) {
collectIds(emp.subordinates);
}
});
};
collectIds(structure);
setExpandedNodes(allIds);
};
const collapseAll = () => {
setExpandedNodes(new Set());
};
const renderEmployeeCard = (employee: OrgEmployee, level: number = 0) => {
const isExpanded = expandedNodes.has(employee.id);
const hasSubordinates = employee.subordinates.length > 0;
const indent = level * 60;
return (
<div key={employee.id} className="relative">
<div
className="flex items-start gap-4 mb-4 group"
style={{ marginLeft: `${indent}px` }}
>
{/* Вертикальная линия для связи с родителем */}
{level > 0 && (
<div className="absolute left-0 top-0 bottom-0 w-px bg-slate-300" style={{ left: `${indent - 20}px` }} />
)}
{/* Кнопка разворачивания */}
{hasSubordinates && (
<button
onClick={() => toggleNode(employee.id)}
className="mt-6 p-1 hover:bg-slate-100 rounded-lg transition-colors flex-shrink-0"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-slate-500" />
) : (
<ChevronRight className="w-4 h-4 text-slate-500" />
)}
</button>
)}
{!hasSubordinates && <div className="w-6" />}
{/* Карточка сотрудника */}
<div
className={`flex-1 bg-white rounded-2xl border-2 transition-all cursor-pointer hover:shadow-lg ${
selectedEmployee?.id === employee.id
? 'border-primary-500 shadow-lg'
: 'border-slate-200 hover:border-primary-300'
}`}
onClick={() => setSelectedEmployee(employee)}
>
<div className="p-4 flex items-start gap-4">
{/* Фото */}
<div className="flex-shrink-0">
{employee.photoUrl ? (
<img
src={employee.photoUrl.startsWith('http') ? employee.photoUrl : `/uploads/${employee.photoUrl}`}
alt={employee.name}
className="w-16 h-16 rounded-xl object-cover border-2 border-slate-200"
onError={(e) => {
(e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(employee.name)}&background=6366f1&color=fff&size=128`;
}}
/>
) : (
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center border-2 border-slate-200">
<span className="text-white font-black text-lg">
{employee.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()}
</span>
</div>
)}
</div>
{/* Информация */}
<div className="flex-1 min-w-0">
<h3 className="font-black text-sm text-slate-800 mb-1 truncate">
{employee.name}
</h3>
<p className="text-xs text-slate-600 font-bold mb-2 truncate">
{employee.position}
</p>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
<Phone className="w-3 h-3" />
<span>{employee.phone}</span>
</div>
{hasSubordinates && (
<div className="mt-2 flex items-center gap-1 text-[10px] text-primary-600 font-bold">
<Users className="w-3 h-3" />
<span>{employee.subordinateCount || employee.subordinates.length} подчиненных</span>
</div>
)}
</div>
{/* Статус */}
<div className="flex-shrink-0">
<span className={`px-2 py-1 rounded-full text-[9px] font-black uppercase ${
employee.status === 'active'
? 'bg-emerald-100 text-emerald-700'
: employee.status === 'vacation'
? 'bg-amber-100 text-amber-700'
: 'bg-slate-100 text-slate-600'
}`}>
{employee.status === 'active' ? 'Активен' : employee.status === 'vacation' ? 'Отпуск' : 'Неактивен'}
</span>
</div>
</div>
</div>
</div>
{/* Горизонтальная линия для связи с подчиненными */}
{hasSubordinates && isExpanded && level >= 0 && (
<div
className="absolute left-0 top-16 w-px h-4 bg-slate-300"
style={{ left: `${indent + 20}px` }}
/>
)}
{/* Подчиненные */}
{hasSubordinates && isExpanded && (
<div className="relative">
{employee.subordinates.map((sub, idx) => (
<div key={sub.id}>
{renderEmployeeCard(sub, level + 1)}
</div>
))}
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[500px]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary-500 mx-auto mb-4" />
<p className="text-sm text-slate-600 font-bold">Загрузка организационной структуры...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[500px]">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-sm text-red-600 font-bold mb-4">{error}</p>
<button
onClick={loadStructure}
className="px-4 py-2 bg-primary-500 text-white rounded-xl text-sm font-bold hover:bg-primary-600 transition-colors"
>
Попробовать снова
</button>
</div>
</div>
);
}
if (structure.length === 0) {
return (
<div className="flex items-center justify-center min-h-[500px]">
<div className="text-center">
<Users className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<p className="text-sm text-slate-600 font-bold">Организационная структура пуста</p>
<p className="text-xs text-slate-500 mt-2">Добавьте сотрудников и укажите их руководителей</p>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Панель управления */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm p-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2">
<h2 className="font-black text-sm text-slate-800">Организационная структура</h2>
<span className="text-xs text-slate-500 font-bold">
({structure.reduce((acc, emp) => {
const count = (e: OrgEmployee) => 1 + e.subordinates.reduce((sum, s) => sum + count(s), 0);
return acc + count(emp);
}, 0)} сотрудников)
</span>
</div>
<div className="flex items-center gap-2">
{/* Управление масштабом */}
<div className="flex items-center gap-2 bg-slate-100 rounded-xl p-1">
<button
onClick={() => setZoom(Math.max(50, zoom - 10))}
className="p-1.5 hover:bg-white rounded-lg transition-colors"
title="Уменьшить"
>
<ZoomOut className="w-4 h-4 text-slate-600" />
</button>
<span className="text-xs font-bold text-slate-700 min-w-[3rem] text-center">{zoom}%</span>
<button
onClick={() => setZoom(Math.min(150, zoom + 10))}
className="p-1.5 hover:bg-white rounded-lg transition-colors"
title="Увеличить"
>
<ZoomIn className="w-4 h-4 text-slate-600" />
</button>
</div>
{/* Управление разворачиванием */}
<button
onClick={expandAll}
className="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 rounded-xl text-xs font-bold text-slate-700 transition-colors"
>
Развернуть все
</button>
<button
onClick={collapseAll}
className="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 rounded-xl text-xs font-bold text-slate-700 transition-colors"
>
Свернуть все
</button>
</div>
</div>
</div>
{/* Структура */}
<div
className="bg-slate-50 rounded-3xl border border-slate-200 shadow-sm p-8 overflow-auto"
style={{
transform: `scale(${zoom / 100})`,
transformOrigin: 'top left',
minHeight: `${500 * (zoom / 100)}px`
}}
>
{structure.map(employee => (
<div key={employee.id}>
{renderEmployeeCard(employee, 0)}
</div>
))}
</div>
{/* Детали выбранного сотрудника */}
{selectedEmployee && (
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm p-6">
<div className="flex items-start justify-between mb-4">
<h3 className="font-black text-sm text-slate-800">Информация о сотруднике</h3>
<button
onClick={() => setSelectedEmployee(null)}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase mb-1">ФИО</p>
<p className="text-sm font-bold text-slate-800">{selectedEmployee.name}</p>
</div>
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase mb-1">Должность</p>
<p className="text-sm font-bold text-slate-800">{selectedEmployee.position}</p>
</div>
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase mb-1">Телефон</p>
<p className="text-sm font-bold text-slate-800">{selectedEmployee.phone}</p>
</div>
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase mb-1">Подчиненные</p>
<p className="text-sm font-bold text-slate-800">
{selectedEmployee.subordinateCount || selectedEmployee.subordinates.length} человек
</p>
</div>
</div>
</div>
)}
</div>
);
};

69
components/hr/PayrollModule.tsx Executable file
View File

@@ -0,0 +1,69 @@
import React from 'react';
import { MOCK_EMPLOYEES } from '../../constants';
import { Banknote, TrendingUp, Wallet, ArrowDownRight, PieChart, Calendar } from 'lucide-react';
export const PayrollModule: React.FC = () => {
const totalPayroll = MOCK_EMPLOYEES.reduce((sum, e) => sum + e.salary, 0);
const avgSalary = Math.round(totalPayroll / MOCK_EMPLOYEES.length);
return (
<div className="space-y-6 animate-fade-in">
{/* Visual Header */}
<div className="bg-gradient-to-br from-emerald-600 to-emerald-800 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
<Banknote className="absolute -top-4 -right-4 w-48 h-48 opacity-10 rotate-12" />
<div className="relative z-10">
<p className="text-emerald-100 text-[10px] font-black uppercase tracking-[0.2em] mb-2">Фонд Оплаты Труда (Месяц)</p>
<h3 className="text-4xl font-black mb-6">{totalPayroll.toLocaleString()} </h3>
<div className="grid grid-cols-2 gap-8">
<div>
<p className="text-[9px] text-emerald-200 font-bold uppercase mb-1">Средний оклад</p>
<p className="text-xl font-black">{avgSalary.toLocaleString()} </p>
</div>
<div>
<p className="text-[9px] text-emerald-200 font-bold uppercase mb-1">Выплата в срок</p>
<p className="text-xl font-black text-white flex items-center gap-2">
100% <TrendingUp className="w-5 h-5 text-emerald-300" />
</p>
</div>
</div>
</div>
</div>
{/* List by Department */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
<h3 className="font-black text-slate-700 text-[10px] uppercase tracking-widest">Детализация начислений</h3>
<div className="flex items-center gap-1.5 text-[10px] font-black text-primary-600 uppercase">
<Calendar className="w-3.5 h-3.5"/> Май 2024
</div>
</div>
<div className="divide-y divide-slate-100">
{MOCK_EMPLOYEES.map(emp => (
<div key={emp.id} className="p-4 flex items-center justify-between hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-500 text-xs">{emp.name[0]}</div>
<div>
<p className="text-xs font-bold text-slate-800">{emp.name}</p>
<p className="text-[9px] text-slate-400 font-bold uppercase">{emp.position}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-black text-slate-900">{emp.salary.toLocaleString()} </p>
<span className="text-[8px] font-black text-emerald-500 uppercase">Оклад + Премия</span>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-blue-50 rounded-2xl border border-blue-100">
<PieChart className="w-5 h-5 text-blue-500 shrink-0" />
<p className="text-[11px] text-blue-700 leading-snug font-medium">
Расходы на персонал составляют 42% от операционного бюджета УК. Рекомендуемый лимит: не более 45%.
</p>
</div>
</div>
);
};

400
components/hr/ProgramModal.tsx Executable file
View File

@@ -0,0 +1,400 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, Users, Check, Search } from 'lucide-react';
import { TrainingProgram, TrainingType, TrainingCategory, Employee } from '../../types';
interface ProgramModalProps {
program?: TrainingProgram | null;
employees?: Employee[];
onClose: () => void;
onSave: (program: TrainingProgram, selectedEmployeeIds?: string[]) => void;
}
export const ProgramModal: React.FC<ProgramModalProps> = ({ program, employees = [], onClose, onSave }) => {
const [formData, setFormData] = useState({
title: '',
description: '',
type: 'instruction' as TrainingType,
category: 'safety' as TrainingCategory,
durationHours: '',
validityMonths: '',
isRequired: false,
requiredForPositions: '',
instructorName: '',
materialsUrl: ''
});
const [selectedEmployees, setSelectedEmployees] = useState<string[]>([]);
const [showEmployeeSelection, setShowEmployeeSelection] = useState(false);
const [employeeSearchQuery, setEmployeeSearchQuery] = useState('');
useEffect(() => {
if (program) {
setFormData({
title: program.title || '',
description: program.description || '',
type: program.type || 'instruction',
category: program.category || 'safety',
durationHours: program.durationHours?.toString() || '',
validityMonths: program.validityMonths?.toString() || '',
isRequired: program.isRequired || false,
requiredForPositions: program.requiredForPositions?.join(', ') || '',
instructorName: program.instructorName || '',
materialsUrl: program.materialsUrl || ''
});
// При редактировании существующей программы показываем выбор сотрудников
setShowEmployeeSelection(true);
} else {
// При создании новой программы выбор сотрудников скрыт по умолчанию
setShowEmployeeSelection(false);
setSelectedEmployees([]);
setEmployeeSearchQuery('');
}
}, [program]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim()) {
alert('Название программы обязательно');
return;
}
const programData: TrainingProgram = {
// ID только для существующих программ, для новых - undefined (backend сгенерирует)
id: program?.id || undefined,
title: formData.title.trim(),
description: formData.description.trim() || undefined,
type: formData.type,
category: formData.category,
durationHours: formData.durationHours ? parseFloat(formData.durationHours) : undefined,
validityMonths: formData.validityMonths ? parseInt(formData.validityMonths) : undefined,
isRequired: formData.isRequired,
requiredForPositions: formData.requiredForPositions
? formData.requiredForPositions.split(',').map(p => p.trim()).filter(p => p)
: undefined,
instructorName: formData.instructorName.trim() || undefined,
materialsUrl: formData.materialsUrl.trim() || undefined
};
// Передаем выбранных сотрудников для массового назначения
onSave(programData, selectedEmployees.length > 0 ? selectedEmployees : undefined);
};
const toggleEmployee = (employeeId: string) => {
setSelectedEmployees(prev =>
prev.includes(employeeId)
? prev.filter(id => id !== employeeId)
: [...prev, employeeId]
);
};
// Фильтрация сотрудников по поисковому запросу
const filteredEmployees = useMemo(() => {
if (!employeeSearchQuery.trim()) {
return employees;
}
const query = employeeSearchQuery.toLowerCase().trim();
return employees.filter(emp =>
emp.name?.toLowerCase().includes(query) ||
emp.position?.toLowerCase().includes(query)
);
}, [employees, employeeSearchQuery]);
const selectAll = () => {
if (selectedEmployees.length === filteredEmployees.length) {
// Снимаем только выбранных из отфильтрованного списка
setSelectedEmployees(prev =>
prev.filter(id => !filteredEmployees.some(emp => emp.id === id))
);
} else {
// Добавляем всех из отфильтрованного списка
const filteredIds = filteredEmployees.map(emp => emp.id);
setSelectedEmployees(prev => {
const newIds = [...new Set([...prev, ...filteredIds])];
return newIds;
});
}
};
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-2xl font-bold text-slate-800">
{program ? `Редактировать программу: ${program.title}` : 'Создать программу обучения'}
</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-6">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Название программы *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
required
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Описание
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Тип обучения *
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as TrainingType })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
required
>
<option value="instruction">Инструктаж</option>
<option value="course">Курс</option>
<option value="certification">Сертификация</option>
<option value="exam">Экзамен</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Категория *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as TrainingCategory })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
required
>
<option value="safety">Техника безопасности</option>
<option value="fire_safety">Пожарная безопасность</option>
<option value="electrical">Электротехническая безопасность</option>
<option value="first_aid">Первая помощь</option>
<option value="professional">Профессиональное обучение</option>
<option value="compliance">Соответствие требованиям</option>
<option value="other">Другое</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Длительность (часы)
</label>
<input
type="number"
step="0.5"
min="0"
value={formData.durationHours}
onChange={(e) => setFormData({ ...formData, durationHours: e.target.value })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Срок действия (месяцев)
</label>
<input
type="number"
min="0"
value={formData.validityMonths}
onChange={(e) => setFormData({ ...formData, validityMonths: e.target.value })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Пусто = бессрочно"
/>
</div>
</div>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isRequired}
onChange={(e) => setFormData({ ...formData, isRequired: e.target.checked })}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="text-sm font-bold text-slate-700">Обязательное обучение</span>
</label>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Должности (через запятую)
</label>
<input
type="text"
value={formData.requiredForPositions}
onChange={(e) => setFormData({ ...formData, requiredForPositions: e.target.value })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Слесарь, Электрик, Мастер"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Инструктор/Преподаватель
</label>
<input
type="text"
value={formData.instructorName}
onChange={(e) => setFormData({ ...formData, instructorName: e.target.value })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Ссылка на материалы
</label>
<input
type="url"
value={formData.materialsUrl}
onChange={(e) => setFormData({ ...formData, materialsUrl: e.target.value })}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
/>
</div>
{/* Выбор сотрудников для массового назначения */}
<div className="border-t border-slate-200 pt-6">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="text-sm font-bold text-slate-700 mb-1 flex items-center gap-2">
<Users className="w-4 h-4"/> Назначить сотрудникам
</h4>
<p className="text-xs text-slate-400">
{selectedEmployees.length > 0
? `Выбрано: ${selectedEmployees.length} сотрудников`
: program
? 'Выберите сотрудников для записи на эту программу'
: 'Выберите сотрудников для автоматического назначения программы'}
</p>
</div>
<button
type="button"
onClick={() => setShowEmployeeSelection(!showEmployeeSelection)}
className="px-4 py-2 text-sm font-bold text-primary-600 hover:bg-primary-50 rounded-xl transition-all"
>
{showEmployeeSelection ? 'Скрыть' : 'Выбрать'}
</button>
</div>
{showEmployeeSelection && employees.length > 0 && (
<div className="max-h-60 overflow-y-auto border border-slate-200 rounded-xl p-4 space-y-2">
{/* Поиск сотрудников */}
<div className="mb-3 pb-3 border-b border-slate-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400"/>
<input
type="text"
value={employeeSearchQuery}
onChange={(e) => setEmployeeSearchQuery(e.target.value)}
placeholder="Поиск по имени или должности..."
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
{filteredEmployees.length > 0 && (
<div className="flex items-center justify-between mb-3 pb-3 border-b border-slate-200">
<button
type="button"
onClick={selectAll}
className="text-xs font-bold text-primary-600 hover:text-primary-700"
>
{filteredEmployees.every(emp => selectedEmployees.includes(emp.id))
? 'Снять все'
: 'Выбрать всех'}
</button>
<span className="text-xs text-slate-400">
{selectedEmployees.filter(id => filteredEmployees.some(emp => emp.id === id)).length} из {filteredEmployees.length}
</span>
</div>
)}
{filteredEmployees.length > 0 ? (
filteredEmployees.map(emp => (
<label
key={emp.id}
className="flex items-center gap-3 p-2 hover:bg-slate-50 rounded-lg cursor-pointer"
>
<input
type="checkbox"
checked={selectedEmployees.includes(emp.id)}
onChange={() => toggleEmployee(emp.id)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div className="flex-1">
<p className="text-sm font-bold text-slate-800">{emp.name}</p>
<p className="text-xs text-slate-400">{emp.position}</p>
</div>
</label>
))
) : (
<div className="text-center py-4 text-slate-400 text-sm">
Сотрудники не найдены
</div>
)}
</div>
)}
{showEmployeeSelection && employees.length === 0 && (
<div className="text-center py-8 text-slate-400 text-sm border border-slate-200 rounded-xl">
Нет доступных сотрудников
</div>
)}
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-3 border border-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-50 transition-all"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 transition-all flex items-center justify-center gap-2"
>
{program ? 'Сохранить' : 'Создать'}
{selectedEmployees.length > 0 && (
<span className="text-xs opacity-90 bg-white/20 px-2 py-0.5 rounded-full">
{selectedEmployees.length} сотрудников
</span>
)}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { MOCK_EMPLOYEES } from '../../constants';
import { ShieldCheck, AlertTriangle, CheckCircle2, ClipboardList, HardHat, Zap, Clock } from 'lucide-react';
export const SafetyBriefing: React.FC = () => {
return (
<div className="space-y-6 animate-fade-in">
{/* Status Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm flex items-center gap-5">
<div className="w-16 h-16 rounded-3xl bg-emerald-50 flex items-center justify-center text-emerald-600">
<ShieldCheck className="w-8 h-8"/>
</div>
<div>
<h4 className="text-2xl font-black text-slate-800 leading-none">22</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1.5 tracking-wider">Инструктаж пройден</p>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-red-100 shadow-sm flex items-center gap-5 relative overflow-hidden">
<div className="absolute right-0 top-0 p-4 opacity-5"><AlertTriangle className="w-20 h-20 text-red-600"/></div>
<div className="w-16 h-16 rounded-3xl bg-red-50 flex items-center justify-center text-red-600">
<AlertTriangle className="w-8 h-8 animate-pulse"/>
</div>
<div>
<h4 className="text-2xl font-black text-red-600 leading-none">2</h4>
<p className="text-[10px] text-red-400 font-bold uppercase mt-1.5 tracking-wider">Просрочено</p>
</div>
</div>
</div>
{/* Safety List */}
<div className="space-y-4">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Журнал по технике безопасности</h3>
<div className="space-y-3">
{MOCK_EMPLOYEES.map(emp => {
const isOverdue = emp.id === 'e2' || emp.id === 'e5'; // Mock logic for demo
return (
<div key={emp.id} className={`bg-white p-4 rounded-2xl border transition-all ${isOverdue ? 'border-red-200 bg-red-50/5' : 'border-slate-100'}`}>
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl ${isOverdue ? 'bg-red-100 text-red-600' : 'bg-slate-50 text-slate-400'}`}>
<HardHat className="w-5 h-5"/>
</div>
<div>
<p className="text-sm font-black text-slate-800 leading-tight">{emp.name}</p>
<p className="text-[9px] text-slate-400 font-bold uppercase">{emp.position}</p>
</div>
</div>
{isOverdue ? (
<span className="text-[8px] font-black bg-red-500 text-white px-2 py-0.5 rounded-full uppercase animate-pulse">Пересдать!</span>
) : (
<div className="flex items-center gap-1 text-[9px] font-black text-emerald-500 uppercase">
<CheckCircle2 className="w-3 h-3"/> Норма
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4 border-t border-slate-100 pt-3">
<div className="flex items-center gap-2">
<Zap className="w-3.5 h-3.5 text-amber-500"/>
<span className="text-[9px] font-bold text-slate-500 uppercase">Эл. безоп.: 3 группа</span>
</div>
<div className="flex items-center gap-2 text-right justify-end">
<Clock className="w-3.5 h-3.5 text-slate-300"/>
<span className="text-[9px] font-bold text-slate-500 uppercase">До: 01.12.2024</span>
</div>
</div>
</div>
)
})}
</div>
</div>
<button className="w-full py-4 bg-slate-900 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] shadow-xl active:scale-95 transition-all flex items-center justify-center gap-3">
<ClipboardList className="w-5 h-5"/> Сформировать приказ об инструктаже
</button>
</div>
);
};

View File

@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import { X, CheckCircle2, XCircle, Play, Clock, UserCheck, Award, FileText } from 'lucide-react';
import { EmployeeTraining, TrainingStatus } from '../../types';
interface TrainingManagementModalProps {
training: EmployeeTraining;
onClose: () => void;
onUpdate: (trainingId: number, updates: Partial<EmployeeTraining>) => Promise<void>;
}
export const TrainingManagementModal: React.FC<TrainingManagementModalProps> = ({
training,
onClose,
onUpdate
}) => {
const [status, setStatus] = useState<TrainingStatus>(training.status || 'in_progress');
const [completionDate, setCompletionDate] = useState(training.completionDate || '');
const [score, setScore] = useState(training.score?.toString() || '');
const [passed, setPassed] = useState(training.passed ?? null);
const [certificateNumber, setCertificateNumber] = useState(training.certificateNumber || '');
const [notes, setNotes] = useState(training.notes || '');
const [saving, setSaving] = useState(false);
const handleStatusChange = (newStatus: TrainingStatus) => {
setStatus(newStatus);
// Автоматически устанавливаем дату завершения при статусе "completed"
if (newStatus === 'completed' && !completionDate) {
setCompletionDate(new Date().toISOString().split('T')[0]);
setPassed(true);
} else if (newStatus === 'failed') {
setPassed(false);
} else if (newStatus === 'cancelled') {
setPassed(null);
}
};
const handleSave = async () => {
try {
setSaving(true);
const updates: Partial<EmployeeTraining> = {
status,
completionDate: completionDate || undefined,
score: score ? parseFloat(score) : undefined,
passed,
certificateNumber: certificateNumber || undefined,
notes: notes || undefined
};
await onUpdate(training.id, updates);
onClose();
} catch (error) {
console.error('Error updating training:', error);
alert('Ошибка при обновлении обучения');
} finally {
setSaving(false);
}
};
const getStatusIcon = (status: TrainingStatus) => {
switch (status) {
case 'completed': return <CheckCircle2 className="w-4 h-4 text-emerald-600"/>;
case 'failed': return <XCircle className="w-4 h-4 text-red-600"/>;
case 'cancelled': return <X className="w-4 h-4 text-slate-400"/>;
case 'in_progress': return <Play className="w-4 h-4 text-amber-600"/>;
case 'not_started': return <Clock className="w-4 h-4 text-slate-400"/>;
default: return <Clock className="w-4 h-4"/>;
}
};
const getStatusLabel = (status: TrainingStatus) => {
const labels: Record<TrainingStatus, string> = {
'not_started': 'Не начато',
'in_progress': 'В процессе',
'completed': 'Завершено',
'failed': 'Не пройдено',
'expired': 'Просрочено',
'cancelled': 'Отменено'
};
return labels[status] || status;
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<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-400 mt-1">
{training.programTitle} {training.employeeName}
</p>
</div>
<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>
<div className="p-6 space-y-6">
{/* Статус обучения */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-3">
Статус обучения
</label>
<div className="grid grid-cols-3 gap-2">
{(['in_progress', 'completed', 'failed', 'cancelled'] as TrainingStatus[]).map((s) => (
<button
key={s}
onClick={() => handleStatusChange(s)}
className={`flex items-center justify-center gap-2 p-3 rounded-xl border-2 transition-all ${
status === s
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 hover:border-slate-300 text-slate-600'
}`}
>
{getStatusIcon(s)}
<span className="text-xs font-bold">{getStatusLabel(s)}</span>
</button>
))}
</div>
</div>
{/* Посещаемость (можно отметить в примечаниях) */}
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100">
<p className="text-xs text-blue-700 font-bold mb-2 flex items-center gap-2">
<UserCheck className="w-4 h-4"/> Посещаемость
</p>
<p className="text-xs text-blue-600">
Отметьте посещаемость в примечаниях ниже (например: "Посетил все занятия" или "Не посетил 2 из 5 занятий")
</p>
</div>
{/* Детали завершения (показываются только для завершенных/не пройденных) */}
{(status === 'completed' || status === 'failed') && (
<>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Дата завершения
</label>
<input
type="date"
value={completionDate}
onChange={(e) => setCompletionDate(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Оценка (баллы)
</label>
<input
type="number"
min="0"
max="100"
value={score}
onChange={(e) => setScore(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="0-100"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Результат
</label>
<div className="flex gap-2">
<button
onClick={() => setPassed(true)}
className={`flex-1 flex items-center justify-center gap-2 p-3 rounded-xl border-2 transition-all ${
passed === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-slate-200 hover:border-slate-300 text-slate-600'
}`}
>
<CheckCircle2 className="w-4 h-4"/> Сдал
</button>
<button
onClick={() => setPassed(false)}
className={`flex-1 flex items-center justify-center gap-2 p-3 rounded-xl border-2 transition-all ${
passed === false
? 'border-red-500 bg-red-50 text-red-700'
: 'border-slate-200 hover:border-slate-300 text-slate-600'
}`}
>
<XCircle className="w-4 h-4"/> Не сдал
</button>
</div>
</div>
</div>
{status === 'completed' && (
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Award className="w-4 h-4"/> Номер сертификата
</label>
<input
type="text"
value={certificateNumber}
onChange={(e) => setCertificateNumber(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="СЕРТ-2024-001"
/>
</div>
)}
</>
)}
{/* Примечания */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<FileText className="w-4 h-4"/> Примечания
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Дополнительная информация об обучении..."
/>
</div>
{/* Информация о программе */}
<div className="bg-slate-50 p-4 rounded-xl">
<p className="text-xs text-slate-400 font-bold uppercase mb-2">Информация о программе</p>
<div className="space-y-1 text-sm">
<p><span className="font-bold">Начало:</span> {training.startDate ? new Date(training.startDate).toLocaleDateString('ru-RU') : 'Не указано'}</p>
{training.expiryDate && (
<p><span className="font-bold">Срок действия до:</span> {new Date(training.expiryDate).toLocaleDateString('ru-RU')}</p>
)}
{training.programDurationHours && (
<p><span className="font-bold">Длительность:</span> {training.programDurationHours} ч.</p>
)}
</div>
</div>
{/* Кнопки действий */}
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
onClick={onClose}
className="flex-1 px-6 py-3 border border-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-50 transition-all"
>
Отмена
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Сохранение...' : 'Сохранить изменения'}
</button>
</div>
</div>
</div>
</div>
);
};

778
components/hr/TrainingModule.tsx Executable file
View File

@@ -0,0 +1,778 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
ShieldCheck, AlertTriangle, CheckCircle2, ClipboardList, HardHat,
Zap, Clock, BookOpen, GraduationCap, Plus, Edit, Trash2, X,
FileText, Calendar, User, Search, Filter, Users, Settings, Building2, MapPin
} from 'lucide-react';
import { Employee, TrainingProgram, EmployeeTraining, TrainingStatus, TrainingType, TrainingCategory } from '../../types';
import { ProgramModal } from './ProgramModal';
import { TrainingManagementModal } from './TrainingManagementModal';
import { BookMeetingRoomModal } from '../office/BookMeetingRoomModal';
import { authFetch } from '../../services/apiClient';
export const TrainingModule: React.FC = () => {
const [employees, setEmployees] = useState<Employee[]>([]);
const [programs, setPrograms] = useState<TrainingProgram[]>([]);
const [trainings, setTrainings] = useState<EmployeeTraining[]>([]);
const [loading, setLoading] = useState(true);
const [selectedEmployee, setSelectedEmployee] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<TrainingStatus | 'all'>('all');
const [filterCategory, setFilterCategory] = useState<TrainingCategory | 'all'>('all');
const [isProgramModalOpen, setIsProgramModalOpen] = useState(false);
const [editingProgram, setEditingProgram] = useState<TrainingProgram | null>(null);
const [managingTraining, setManagingTraining] = useState<EmployeeTraining | null>(null);
const [trainingForBooking, setTrainingForBooking] = useState<EmployeeTraining | null>(null);
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const activeEmployees = useMemo(
() => employees.filter((e) => e.status !== 'inactive'),
[employees]
);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
// Загружаем сотрудников
const employeesUrl = (import.meta.env.DEV || !apiBaseUrl)
? '/api/employees'
: `${apiBaseUrl}/employees`;
const employeesRes = await authFetch(employeesUrl);
if (employeesRes.ok) {
const employeesData = await employeesRes.json();
setEmployees(employeesData);
}
// Загружаем программы обучения
try {
const programsUrl = (import.meta.env.DEV || !apiBaseUrl)
? '/api/training/programs'
: `${apiBaseUrl}/training/programs`;
const programsRes = await authFetch(programsUrl);
if (programsRes.ok) {
const programsData = await programsRes.json();
setPrograms(programsData);
} else if (programsRes.status === 404) {
// Endpoint еще не создан - это нормально, просто пустой список
console.warn('Training programs endpoint not found - server may need restart');
setPrograms([]);
}
} catch (error) {
console.warn('Error fetching training programs:', error);
setPrograms([]);
}
// Загружаем все обучения
await fetchTrainings();
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const fetchTrainings = async () => {
try {
if (selectedEmployee) {
const trainingsUrl = (import.meta.env.DEV || !apiBaseUrl)
? `/api/training/employee/${selectedEmployee}`
: `${apiBaseUrl}/training/employee/${selectedEmployee}`;
const trainingsRes = await authFetch(trainingsUrl);
if (trainingsRes.ok) {
const trainingsData = await trainingsRes.json();
setTrainings(trainingsData);
} else if (trainingsRes.status === 404) {
console.warn('Training endpoint not found - server may need restart');
setTrainings([]);
}
} else {
// Загружаем все назначенные обучения для всех сотрудников
try {
const allTrainingsUrl = (import.meta.env.DEV || !apiBaseUrl)
? '/api/training/all'
: `${apiBaseUrl}/training/all`;
const allTrainingsRes = await authFetch(allTrainingsUrl);
if (allTrainingsRes.ok) {
const allTrainingsData = await allTrainingsRes.json();
setTrainings(allTrainingsData || []);
} else if (allTrainingsRes.status === 404) {
// Если endpoint не найден, пробуем загрузить просроченные
console.warn('Training all endpoint not found, trying overdue...');
const overdueUrl = (import.meta.env.DEV || !apiBaseUrl)
? '/api/training/overdue'
: `${apiBaseUrl}/training/overdue`;
const overdueRes = await authFetch(overdueUrl);
if (overdueRes.ok) {
const overdueData = await overdueRes.json();
setTrainings(overdueData || []);
} else {
setTrainings([]);
}
}
} catch (error) {
console.warn('Error fetching all trainings:', error);
setTrainings([]);
}
}
} catch (error) {
console.error('Error fetching trainings:', error);
setTrainings([]);
}
};
useEffect(() => {
fetchTrainings();
}, [selectedEmployee]);
// Статистика
const stats = {
total: trainings.length,
completed: trainings.filter(t => t.status === 'completed').length,
inProgress: trainings.filter(t => t.status === 'in_progress').length,
expired: trainings.filter(t => {
if (!t.expiryDate) return false;
return new Date(t.expiryDate) < new Date() && t.status === 'completed';
}).length,
overdue: trainings.filter(t => {
if (!t.expiryDate) return false;
return new Date(t.expiryDate) < new Date();
}).length
};
// Фильтрация
const filteredTrainings = trainings.filter(t => {
if (filterStatus !== 'all' && t.status !== filterStatus) return false;
if (filterCategory !== 'all' && t.programCategory !== filterCategory) return false;
return true;
});
const getStatusColor = (status: TrainingStatus, expiryDate?: string) => {
if (expiryDate && new Date(expiryDate) < new Date() && status === 'completed') {
return 'bg-red-50 text-red-600 border-red-200';
}
switch (status) {
case 'completed': return 'bg-emerald-50 text-emerald-600 border-emerald-200';
case 'in_progress': return 'bg-amber-50 text-amber-600 border-amber-200';
case 'failed': return 'bg-red-50 text-red-600 border-red-200';
case 'expired': return 'bg-red-50 text-red-600 border-red-200';
case 'cancelled': return 'bg-slate-50 text-slate-600 border-slate-200';
default: return 'bg-slate-50 text-slate-400 border-slate-200';
}
};
const getStatusLabel = (status: TrainingStatus) => {
const labels: Record<TrainingStatus, string> = {
not_started: 'Не начато',
in_progress: 'В процессе',
completed: 'Завершено',
failed: 'Не пройдено',
expired: 'Просрочено',
cancelled: 'Отменено'
};
return labels[status] || status;
};
const getCategoryLabel = (category: TrainingCategory) => {
const labels: Record<TrainingCategory, string> = {
safety: 'Техника безопасности',
fire_safety: 'Пожарная безопасность',
electrical: 'Электротехническая безопасность',
first_aid: 'Первая помощь',
professional: 'Профессиональное обучение',
compliance: 'Соответствие требованиям',
other: 'Другое'
};
return labels[category] || category;
};
const getTypeLabel = (type: TrainingType) => {
const labels: Record<TrainingType, string> = {
instruction: 'Инструктаж',
course: 'Курс',
certification: 'Сертификация',
exam: 'Экзамен',
other: 'Другое'
};
return labels[type] || type;
};
const handleSaveProgram = async (program: TrainingProgram, selectedEmployeeIds?: string[]) => {
try {
// Определяем, создаем новую программу или редактируем существующую
// Проверяем, что program существует и имеет валидный ID
const isEdit = !!(program && program.id && typeof program.id === 'string' && program.id.trim() !== '');
const url = (import.meta.env.DEV || !apiBaseUrl)
? (isEdit ? `/api/training/programs/${program.id}` : '/api/training/programs')
: (isEdit ? `${apiBaseUrl}/training/programs/${program.id}` : `${apiBaseUrl}/training/programs`);
const method = isEdit ? 'PUT' : 'POST';
// Преобразуем данные в формат, который ожидает backend
// При создании новой программы НЕ отправляем id (backend сам сгенерирует)
const requestData: any = {
title: program.title,
description: program.description,
type: program.type,
category: program.category,
durationHours: program.durationHours,
validityMonths: program.validityMonths,
isRequired: program.isRequired,
requiredForPositions: program.requiredForPositions,
instructorName: program.instructorName,
materialsUrl: program.materialsUrl
};
// Добавляем id только при редактировании существующей программы
if (isEdit) {
requestData.id = program.id;
}
const response = await authFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
});
if (response.ok) {
const savedProgram = await response.json();
// Проверяем, что программа была успешно создана/обновлена
if (!savedProgram) {
alert('Ошибка: Программа не была создана. Проверьте данные и попробуйте снова.');
return;
}
// Получаем ID программы (может быть в разных форматах после нормализации)
const programId = savedProgram.id;
if (!programId) {
console.error('Saved program response:', savedProgram);
alert('Ошибка: Не удалось получить ID созданной программы. Проверьте консоль для деталей.');
return;
}
// Если выбраны сотрудники, назначаем им обучение массово
if (selectedEmployeeIds && selectedEmployeeIds.length > 0) {
// Небольшая задержка, чтобы убедиться, что программа сохранена в БД
await new Promise(resolve => setTimeout(resolve, 200));
const assigned = await assignTrainingToEmployees(programId, selectedEmployeeIds);
if (assigned > 0) {
if (isEdit) {
alert(`Программа обновлена. Обучение назначено ${assigned} сотрудникам`);
} else {
alert(`Программа создана и назначена ${assigned} сотрудникам`);
}
} else {
if (isEdit) {
alert('Программа обновлена, но не удалось назначить обучение сотрудникам. Проверьте данные.');
} else {
alert('Программа создана, но не удалось назначить обучение сотрудникам. Проверьте данные.');
}
}
} else if (isEdit) {
// Если редактируем программу без выбора сотрудников, просто сохраняем
alert('Программа обновлена');
} else {
alert('Программа успешно создана');
}
setIsProgramModalOpen(false);
setEditingProgram(null);
await fetchData();
} else {
let errorMessage = `Ошибка ${response.status}: ${response.statusText}`;
try {
const error = await response.json();
errorMessage = error.error || error.details || errorMessage;
} catch (e) {
const text = await response.text().catch(() => '');
if (text) errorMessage = text;
}
alert(`Ошибка: ${errorMessage}`);
}
} catch (error) {
console.error('Error saving program:', error);
alert('Ошибка при сохранении программы');
}
};
const handleUpdateTraining = async (trainingId: number, updates: Partial<EmployeeTraining>) => {
try {
const training = trainings.find(t => t.id === trainingId);
if (!training) {
throw new Error('Обучение не найдено');
}
const url = (import.meta.env.DEV || !apiBaseUrl)
? `/api/training/employee/${training.employeeId}/${trainingId}`
: `${apiBaseUrl}/training/employee/${training.employeeId}/${trainingId}`;
const response = await authFetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Не удалось обновить обучение');
}
// Обновляем список обучений
await fetchTrainings();
} catch (error) {
console.error('Error updating training:', error);
throw error;
}
};
const assignTrainingToEmployees = async (programId: string, employeeIds: string[]) => {
const startDate = new Date().toISOString().split('T')[0];
let assignedCount = 0;
const errors: string[] = [];
for (const employeeId of employeeIds) {
try {
const url = (import.meta.env.DEV || !apiBaseUrl)
? `/api/training/employee/${employeeId}`
: `${apiBaseUrl}/training/employee/${employeeId}`;
const response = await authFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
programId,
startDate
})
});
if (response.ok) {
assignedCount++;
} else {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
const errorMsg = errorData.error || `Ошибка ${response.status}`;
errors.push(`Сотрудник ${employeeId}: ${errorMsg}`);
console.error(`Error assigning training to employee ${employeeId}:`, errorMsg);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Неизвестная ошибка';
errors.push(`Сотрудник ${employeeId}: ${errorMsg}`);
console.error(`Error assigning training to employee ${employeeId}:`, error);
}
}
if (errors.length > 0 && assignedCount === 0) {
console.error('Все назначения завершились ошибкой:', errors);
}
return assignedCount;
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="text-slate-400 text-sm">Загрузка данных...</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm flex items-center gap-5">
<div className="w-16 h-16 rounded-3xl bg-emerald-50 flex items-center justify-center text-emerald-600">
<CheckCircle2 className="w-8 h-8"/>
</div>
<div>
<h4 className="text-2xl font-black text-slate-800 leading-none">{stats.completed}</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1.5 tracking-wider">Завершено</p>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-amber-100 shadow-sm flex items-center gap-5">
<div className="w-16 h-16 rounded-3xl bg-amber-50 flex items-center justify-center text-amber-600">
<Clock className="w-8 h-8"/>
</div>
<div>
<h4 className="text-2xl font-black text-amber-600 leading-none">{stats.inProgress}</h4>
<p className="text-[10px] text-amber-400 font-bold uppercase mt-1.5 tracking-wider">В процессе</p>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-red-100 shadow-sm flex items-center gap-5 relative overflow-hidden">
<div className="absolute right-0 top-0 p-4 opacity-5">
<AlertTriangle className="w-20 h-20 text-red-600"/>
</div>
<div className="w-16 h-16 rounded-3xl bg-red-50 flex items-center justify-center text-red-600">
<AlertTriangle className="w-8 h-8 animate-pulse"/>
</div>
<div>
<h4 className="text-2xl font-black text-red-600 leading-none">{stats.expired}</h4>
<p className="text-[10px] text-red-400 font-bold uppercase mt-1.5 tracking-wider">Просрочено</p>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm flex items-center gap-5">
<div className="w-16 h-16 rounded-3xl bg-primary-50 flex items-center justify-center text-primary-600">
<BookOpen className="w-8 h-8"/>
</div>
<div>
<h4 className="text-2xl font-black text-slate-800 leading-none">{programs.length}</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1.5 tracking-wider">Программ</p>
</div>
</div>
</div>
{/* Фильтры и действия */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-slate-400"/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as TrainingStatus | 'all')}
className="px-3 py-2 border border-slate-200 rounded-xl text-sm font-bold text-slate-700 bg-white"
>
<option value="all">Все статусы</option>
<option value="not_started">Не начато</option>
<option value="in_progress">В процессе</option>
<option value="completed">Завершено</option>
<option value="failed">Не пройдено</option>
<option value="expired">Просрочено</option>
</select>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value as TrainingCategory | 'all')}
className="px-3 py-2 border border-slate-200 rounded-xl text-sm font-bold text-slate-700 bg-white"
>
<option value="all">Все категории</option>
<option value="safety">Техника безопасности</option>
<option value="fire_safety">Пожарная безопасность</option>
<option value="electrical">Электротехническая безопасность</option>
<option value="first_aid">Первая помощь</option>
<option value="professional">Профессиональное обучение</option>
<option value="compliance">Соответствие требованиям</option>
</select>
<select
value={selectedEmployee || 'all'}
onChange={(e) => setSelectedEmployee(e.target.value === 'all' ? null : e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-xl text-sm font-bold text-slate-700 bg-white"
>
<option value="all">Все сотрудники</option>
{activeEmployees.map(emp => (
<option key={emp.id} value={emp.id}>{emp.name}</option>
))}
</select>
</div>
<div className="flex gap-2 ml-auto">
<button
onClick={() => {
setEditingProgram(null);
setIsProgramModalOpen(true);
}}
className="px-4 py-2 bg-primary-600 text-white rounded-xl text-[10px] font-black uppercase flex items-center gap-2 hover:bg-primary-700 transition-all"
>
<Plus className="w-4 h-4"/> Программа
</button>
</div>
</div>
{/* Список программ обучения */}
{programs.length > 0 && (
<div className="space-y-4 mb-6">
<div className="flex items-center justify-between px-1">
<div>
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">
Программы обучения (шаблоны)
</h3>
<p className="text-[9px] text-slate-400 font-bold mt-1">
Создайте программу и назначьте её сотрудникам записи появятся ниже
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{programs.map(prog => (
<div
key={prog.id}
className="bg-white p-4 rounded-2xl border-2 border-slate-200 hover:border-primary-300 transition-all"
>
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h4 className="text-sm font-black text-slate-800 leading-tight mb-1">
{prog.title}
</h4>
<p className="text-xs text-slate-400 font-bold uppercase">
{getTypeLabel(prog.type)} {getCategoryLabel(prog.category)}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setEditingProgram(prog);
setIsProgramModalOpen(true);
}}
className="p-1.5 text-slate-400 hover:text-primary-600 transition-colors"
title="Редактировать"
>
<Edit className="w-4 h-4"/>
</button>
<button
onClick={() => {
setEditingProgram(prog);
setIsProgramModalOpen(true);
}}
className="p-1.5 text-slate-400 hover:text-emerald-600 transition-colors"
title="Записать сотрудников на программу"
>
<Users className="w-4 h-4"/>
</button>
</div>
</div>
{prog.description && (
<p className="text-xs text-slate-500 mb-2 line-clamp-2">{prog.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-slate-400">
{prog.durationHours && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3"/> {prog.durationHours} ч.
</span>
)}
{prog.validityMonths && (
<span>Срок: {prog.validityMonths} мес.</span>
)}
{prog.isRequired && (
<span className="text-amber-600 font-bold">Обязательное</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Список обучений */}
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<div>
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">
{selectedEmployee ? 'Обучение сотрудника' : 'Назначенные обучения'}
</h3>
<p className="text-[9px] text-slate-400 font-bold mt-1">
{selectedEmployee
? 'Обучение, назначенное выбранному сотруднику'
: 'Конкретные назначения программ сотрудникам. Назначьте программу выше, чтобы здесь появились записи'}
</p>
</div>
</div>
{filteredTrainings.length === 0 ? (
<div className="bg-white p-8 rounded-2xl border border-slate-100 text-center">
<BookOpen className="w-12 h-12 text-slate-300 mx-auto mb-3"/>
<p className="text-slate-400 text-sm font-bold mb-2">Нет назначенных обучений</p>
<p className="text-[10px] text-slate-400 font-bold">
{programs.length > 0
? 'Выберите программу выше и нажмите иконку 👥, чтобы назначить её сотрудникам'
: 'Создайте программу обучения, затем назначьте её сотрудникам'}
</p>
</div>
) : (
<div className="space-y-3">
{filteredTrainings.map(training => {
const isExpired = training.expiryDate && new Date(training.expiryDate) < new Date();
const isOverdue = isExpired && training.status === 'completed';
return (
<div
key={training.id}
className={`bg-white p-4 rounded-2xl border-2 transition-all ${
isOverdue ? 'border-red-200 bg-red-50/5' : getStatusColor(training.status, training.expiryDate)
}`}
>
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-3 flex-1">
<div className={`p-2.5 rounded-xl ${
isOverdue ? 'bg-red-100 text-red-600' :
training.status === 'completed' ? 'bg-emerald-100 text-emerald-600' :
'bg-slate-50 text-slate-400'
}`}>
{training.programCategory === 'safety' || training.programCategory === 'electrical' ? (
<HardHat className="w-5 h-5"/>
) : training.programCategory === 'fire_safety' ? (
<Zap className="w-5 h-5"/>
) : (
<GraduationCap className="w-5 h-5"/>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-black text-slate-800 leading-tight">
{training.programTitle || 'Неизвестная программа'}
</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-[9px] text-slate-400 font-bold uppercase">
{training.employeeName || 'Неизвестный сотрудник'}
</span>
{training.employeePosition && (
<>
<span className="text-slate-300"></span>
<span className="text-[9px] text-slate-400 font-bold uppercase">
{training.employeePosition}
</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isOverdue ? (
<span className="text-[8px] font-black bg-red-500 text-white px-2 py-0.5 rounded-full uppercase animate-pulse">
Пересдать!
</span>
) : training.status === 'completed' ? (
<div className="flex items-center gap-1 text-[9px] font-black text-emerald-500 uppercase">
<CheckCircle2 className="w-3 h-3"/> Завершено
</div>
) : (
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${
training.status === 'in_progress' ? 'bg-amber-100 text-amber-600' :
training.status === 'failed' ? 'bg-red-100 text-red-600' :
training.status === 'cancelled' ? 'bg-slate-100 text-slate-400' :
'bg-slate-100 text-slate-500'
}`}>
{getStatusLabel(training.status)}
</span>
)}
<button
onClick={() => setTrainingForBooking(training)}
className="p-1.5 text-slate-400 hover:text-amber-600 transition-colors"
title="Забронировать переговорную для обучения"
>
<Building2 className="w-4 h-4"/>
</button>
<button
onClick={() => setManagingTraining(training)}
className="p-1.5 text-slate-400 hover:text-primary-600 transition-colors"
title="Управление обучением"
>
<Edit className="w-4 h-4"/>
</button>
</div>
</div>
{training.location && (
<div className="flex items-center gap-2 mb-2 text-[9px] font-bold text-slate-500 uppercase">
<MapPin className="w-3.5 h-3.5 text-slate-400"/>
<span>Где: {training.location}</span>
</div>
)}
<div className="grid grid-cols-2 gap-4 border-t border-slate-100 pt-3">
<div className="flex items-center gap-2">
<FileText className="w-3.5 h-3.5 text-slate-400"/>
<span className="text-[9px] font-bold text-slate-500 uppercase">
{getTypeLabel(training.programType || 'other')} {getCategoryLabel(training.programCategory || 'other')}
</span>
</div>
<div className="flex items-center gap-2 text-right justify-end">
{training.expiryDate ? (
<>
<Clock className={`w-3.5 h-3.5 ${isExpired ? 'text-red-500' : 'text-slate-300'}`}/>
<span className={`text-[9px] font-bold uppercase ${isExpired ? 'text-red-600' : 'text-slate-500'}`}>
До: {new Date(training.expiryDate).toLocaleDateString('ru-RU')}
</span>
</>
) : training.completionDate ? (
<>
<Calendar className="w-3.5 h-3.5 text-slate-300"/>
<span className="text-[9px] font-bold text-slate-500 uppercase">
{new Date(training.completionDate).toLocaleDateString('ru-RU')}
</span>
</>
) : null}
</div>
</div>
{training.certificateNumber && (
<div className="mt-2 pt-2 border-t border-slate-100">
<div className="flex items-center gap-2">
<FileText className="w-3 h-3 text-slate-400"/>
<span className="text-[9px] font-bold text-slate-500">
Сертификат: {training.certificateNumber}
</span>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Кнопка формирования приказа */}
<button className="w-full py-4 bg-slate-900 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] shadow-xl active:scale-95 transition-all flex items-center justify-center gap-3">
<ClipboardList className="w-5 h-5"/> Сформировать приказ об инструктаже
</button>
{/* Модальное окно создания/редактирования программы */}
{isProgramModalOpen && (
<ProgramModal
program={editingProgram || undefined}
employees={activeEmployees}
onClose={() => {
setIsProgramModalOpen(false);
setEditingProgram(null);
}}
onSave={handleSaveProgram}
/>
)}
{/* Модальное окно управления обучением */}
{managingTraining && (
<TrainingManagementModal
training={managingTraining}
onClose={() => setManagingTraining(null)}
onUpdate={handleUpdateTraining}
/>
)}
{/* Бронирование переговорной для обучения */}
{trainingForBooking && (
<BookMeetingRoomModal
purpose={`Обучение: ${trainingForBooking.programTitle || 'Программа'}${trainingForBooking.employeeName || 'Сотрудник'}`}
defaultDate={(() => {
try {
const d = trainingForBooking.startDate;
if (d == null || d === '') return undefined;
const parsed = new Date(d);
return isNaN(parsed.getTime()) ? undefined : parsed.toISOString().split('T')[0];
} catch {
return undefined;
}
})()}
onBooked={async (roomName) => {
if (roomName) {
try {
await handleUpdateTraining(trainingForBooking.id, { location: roomName });
} catch (e) {
console.error(e);
}
}
setTrainingForBooking(null);
}}
onClose={() => setTrainingForBooking(null)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,432 @@
import React, { useState, useEffect } from 'react';
import { Vacancy, Candidate } from '../../types';
import { Briefcase, Users, Megaphone, Plus, ExternalLink, Filter, Search, MoreVertical, AlertCircle, Clock, Edit, Trash2, X } from 'lucide-react';
import { VacancyFormModal } from './VacancyFormModal';
import { CandidateFormModal } from './CandidateFormModal';
import { authFetch } from '../../services/apiClient';
export const VacanciesRegistry: React.FC = () => {
const [vacancies, setVacancies] = useState<Vacancy[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
const [editingVacancy, setEditingVacancy] = useState<Vacancy | null>(null);
const [selectedVacancy, setSelectedVacancy] = useState<Vacancy | null>(null);
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [showCandidates, setShowCandidates] = useState(false);
const [isCandidateFormOpen, setIsCandidateFormOpen] = useState(false);
const [candidateVacancyId, setCandidateVacancyId] = useState<string | null>(null);
const [editingCandidate, setEditingCandidate] = useState<Candidate | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchVacancies();
}, []);
const fetchVacancies = async () => {
try {
setLoading(true);
setError(null);
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);
} 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 vacancies:', errorMessage, errorData);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch vacancies';
setError(errorMessage);
console.error('Error fetching vacancies:', error);
} finally {
setLoading(false);
}
};
const fetchCandidates = async (vacancyId: string) => {
try {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const apiUrl = (import.meta.env.DEV || !apiBaseUrl)
? `/api/vacancies/${vacancyId}/candidates`
: `${apiBaseUrl}/vacancies/${vacancyId}/candidates`;
const response = await authFetch(apiUrl);
if (response.ok) {
const data = await response.json();
setCandidates(data);
}
} catch (error) {
console.error('Error fetching candidates:', error);
}
};
const handleCreateVacancy = () => {
setEditingVacancy(null);
setIsFormModalOpen(true);
};
const handleEditVacancy = (vacancy: Vacancy) => {
setEditingVacancy(vacancy);
setIsFormModalOpen(true);
};
const handleDeleteVacancy = async (vacancy: Vacancy) => {
if (!confirm(`Удалить вакансию "${vacancy.position}"?`)) return;
try {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const apiUrl = (import.meta.env.DEV || !apiBaseUrl)
? `/api/vacancies/${vacancy.id}`
: `${apiBaseUrl}/vacancies/${vacancy.id}`;
const response = await authFetch(apiUrl, { method: 'DELETE' });
if (response.ok) {
await fetchVacancies();
} else {
alert('Ошибка при удалении вакансии');
}
} catch (error) {
console.error('Error deleting vacancy:', error);
alert('Ошибка при удалении вакансии');
}
};
const handleShowCandidates = async (vacancy: Vacancy) => {
setSelectedVacancy(vacancy);
await fetchCandidates(vacancy.id);
setShowCandidates(true);
};
const handleCreateCandidateForVacancy = (vacancy: Vacancy) => {
setEditingCandidate(null);
setCandidateVacancyId(vacancy.id);
setIsCandidateFormOpen(true);
};
const handleOpenCandidateCard = (candidate: Candidate) => {
setEditingCandidate(candidate);
setCandidateVacancyId(selectedVacancy?.id || candidate.vacancyId || null);
setShowCandidates(false);
setIsCandidateFormOpen(true);
};
const handleSaveCandidate = async (candidate: Candidate) => {
setIsCandidateFormOpen(false);
setCandidateVacancyId(null);
setEditingCandidate(null);
await fetchVacancies(); // Обновляем список вакансий для обновления счетчика кандидатов
if (selectedVacancy) await fetchCandidates(selectedVacancy.id); // обновить список кандидатов, если модалка снова откроют
};
const handleSaveVacancy = async (vacancy: Vacancy) => {
await fetchVacancies();
setIsFormModalOpen(false);
setEditingVacancy(null);
};
const filteredVacancies = vacancies.filter(v => {
const query = searchQuery.toLowerCase();
return v.position.toLowerCase().includes(query) ||
v.department.toLowerCase().includes(query) ||
(v.description && v.description.toLowerCase().includes(query));
});
const activeVacancies = vacancies.filter(v => v.status === 'active' || v.status === 'urgent');
const totalApplicants = vacancies.reduce((sum, v) => sum + (v.applicantsCount || 0), 0);
const closedVacancies = vacancies.filter(v => v.status === 'closed').length;
const getStatusLabel = (status: string) => {
switch (status) {
case 'urgent': return 'Срочно';
case 'active': return 'Активна';
case 'paused': return 'Приостановлена';
case 'closed': return 'Закрыта';
default: return status;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'urgent': return 'bg-red-50 text-red-600 animate-pulse';
case 'active': return 'bg-emerald-50 text-emerald-600';
case 'paused': return 'bg-amber-50 text-amber-600';
case 'closed': return 'bg-slate-50 text-slate-600';
default: return 'bg-slate-50 text-slate-600';
}
};
const getStatusLineColor = (status: string) => {
switch (status) {
case 'urgent': return 'bg-red-500';
case 'active': return 'bg-emerald-500';
case 'paused': return 'bg-amber-500';
case 'closed': return 'bg-slate-400';
default: return 'bg-slate-400';
}
};
return (
<div className="space-y-6 animate-fade-in">
{/* Vacancy 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">
<Briefcase 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">
<Megaphone 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">
Всего {activeVacancies.length} активных вакансий. Текущий срок закрытия позиции в среднем: 14 дней.
</p>
<div className="flex gap-4">
<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">{totalApplicants}</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">{closedVacancies}</p>
</div>
</div>
</div>
</div>
{/* Кнопка создания вакансии - над списком */}
<button
onClick={handleCreateVacancy}
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={fetchVacancies}
className="mt-3 text-sm font-medium text-red-600 hover:text-red-800 underline"
>
Попробовать снова
</button>
</div>
</div>
)}
{/* List of Vacancies */}
{loading ? (
<div className="text-center py-10 text-slate-400">Загрузка...</div>
) : error ? null : filteredVacancies.length === 0 ? (
<div className="text-center py-10 text-slate-400">
{searchQuery ? 'Вакансии не найдены' : 'Нет вакансий. Создайте первую вакансию.'}
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{filteredVacancies.map(vacancy => (
<div key={vacancy.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 ${getStatusLineColor(vacancy.status)}`}/>
<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 ${getStatusColor(vacancy.status)}`}>
{getStatusLabel(vacancy.status)}
</span>
<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">{vacancy.department}</span>
<span className="text-[9px] font-black text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full uppercase tracking-tighter flex items-center gap-1">
<Clock className="w-3 h-3"/> Опубликовано: {vacancy.postedDate}
</span>
</div>
<h4 className="text-xl font-black text-slate-800 leading-tight group-hover:text-primary-600 transition-colors mb-2">{vacancy.position}</h4>
<p className="text-xs text-slate-500 font-medium leading-relaxed line-clamp-2 max-w-2xl">{vacancy.description}</p>
</div>
<div className="text-right flex flex-col justify-between items-end min-w-[150px]">
<div>
{vacancy.salary && (
<p className="text-lg font-black text-slate-900">{vacancy.salary}</p>
)}
<div className="flex items-center gap-1.5 justify-end mt-1 text-primary-600 font-bold">
<Users className="w-4 h-4"/>
<span className="text-xs">{vacancy.applicantsCount || 0} откликов</span>
</div>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={() => handleEditVacancy(vacancy)}
className="p-2 text-slate-300 hover:text-primary-600 transition-colors"
title="Редактировать"
>
<Edit className="w-5 h-5"/>
</button>
<button
onClick={() => handleDeleteVacancy(vacancy)}
className="p-2 text-slate-300 hover:text-red-600 transition-colors"
title="Удалить"
>
<Trash2 className="w-5 h-5"/>
</button>
</div>
</div>
</div>
{/* Posting Actions */}
<div className="mt-6 pt-5 border-t border-slate-50 flex flex-wrap gap-2">
<button
onClick={() => handleCreateCandidateForVacancy(vacancy)}
className="bg-emerald-600 text-white px-8 py-3 rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-emerald-500/20 active:scale-95 transition-all"
>
<Plus className="w-4 h-4"/> Добавить кандидата
</button>
<button
onClick={() => handleShowCandidates(vacancy)}
className="bg-primary-600 text-white px-8 py-3 rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 active:scale-95 transition-all"
>
<Users className="w-4 h-4"/> Показать кандидатов ({vacancy.applicantsCount || 0})
</button>
</div>
</div>
))}
</div>
)}
<div className="flex items-center gap-3 p-4 bg-amber-50 rounded-2xl border border-amber-100">
<AlertCircle className="w-5 h-5 text-amber-500 shrink-0" />
<p className="text-[11px] text-amber-700 leading-snug font-medium">
Уровень зарплат по рабочим вакансиям (сантехники, электрики) вырос на 15% в регионе за последний квартал. Рекомендуем пересмотреть вилки для ускорения найма.
</p>
</div>
{/* Модальное окно создания/редактирования вакансии */}
{isFormModalOpen && (
<VacancyFormModal
vacancy={editingVacancy}
onClose={() => {
setIsFormModalOpen(false);
setEditingVacancy(null);
}}
onSave={handleSaveVacancy}
/>
)}
{/* Модальное окно создания/редактирования кандидата */}
{isCandidateFormOpen && (
<CandidateFormModal
candidate={editingCandidate}
vacancyId={candidateVacancyId}
vacancies={vacancies}
onClose={() => {
setIsCandidateFormOpen(false);
setCandidateVacancyId(null);
setEditingCandidate(null);
}}
onSave={handleSaveCandidate}
/>
)}
{/* Модальное окно кандидатов */}
{showCandidates && selectedVacancy && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={() => setShowCandidates(false)}
>
<div
className="bg-white rounded-2xl w-full max-w-3xl 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">{selectedVacancy.position}</p>
</div>
<button
onClick={() => setShowCandidates(false)}
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">
<div className="mb-4">
<button
onClick={() => {
setShowCandidates(false);
handleCreateCandidateForVacancy(selectedVacancy);
}}
className="w-full py-3 bg-primary-600 text-white rounded-xl text-xs 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-4 h-4"/> Добавить кандидата
</button>
</div>
{candidates.length === 0 ? (
<div className="text-center py-10 text-slate-400">
Нет кандидатов на эту вакансию
</div>
) : (
<div className="space-y-3">
{candidates.map(candidate => (
<button
key={candidate.id}
type="button"
onClick={() => handleOpenCandidateCard(candidate)}
className="w-full text-left bg-slate-50 p-4 rounded-xl border border-slate-200 hover:bg-primary-50 hover:border-primary-200 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-bold text-slate-800">{candidate.name}</h4>
<p className="text-sm text-slate-500">{candidate.position}</p>
<p className="text-xs text-slate-400 mt-1">{candidate.phone}</p>
</div>
<span className={`text-[9px] font-black px-2 py-1 rounded-full uppercase ${
candidate.stage === 'new' ? 'bg-blue-50 text-blue-600' :
candidate.stage === 'interview' ? 'bg-amber-50 text-amber-600' :
candidate.stage === 'probation' ? 'bg-purple-50 text-purple-600' :
candidate.stage === 'hired' ? 'bg-green-50 text-green-600' :
'bg-red-50 text-red-600'
}`}>
{candidate.stage === 'new' ? 'Новый' :
candidate.stage === 'interview' ? 'Собеседование' :
candidate.stage === 'probation' ? 'Испытательный срок' :
candidate.stage === 'hired' ? 'Трудоустроен' : 'Отклонен'}
</span>
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,291 @@
import React, { useState } from 'react';
import { Vacancy } from '../../types';
import { X, Briefcase, DollarSign, FileText, Users, Calendar, AlertCircle } from 'lucide-react';
import { authFetch } from '../../services/apiClient';
interface VacancyFormModalProps {
vacancy?: Vacancy | null;
onClose: () => void;
onSave: (vacancy: Vacancy) => void;
}
export const VacancyFormModal: React.FC<VacancyFormModalProps> = ({ vacancy, onClose, onSave }) => {
const isEditMode = !!vacancy;
const [formData, setFormData] = useState({
position: vacancy?.position || '',
department: vacancy?.department || '',
status: vacancy?.status || 'active' as 'urgent' | 'active' | 'paused' | 'closed',
salary: vacancy?.salary || '',
description: vacancy?.description || '',
requirements: vacancy?.requirements || '',
conditions: vacancy?.conditions || '',
responsibilities: vacancy?.responsibilities || '',
postedDate: vacancy?.postedDate || new Date().toISOString().split('T')[0],
closingDate: vacancy?.closingDate || '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.position || !formData.department || !formData.description) {
alert('Заполните обязательные поля: позиция, отдел и описание');
return;
}
try {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const apiUrl = (import.meta.env.DEV || !apiBaseUrl)
? `/api/vacancies${isEditMode ? `/${vacancy.id}` : ''}`
: `${apiBaseUrl}/vacancies${isEditMode ? `/${vacancy.id}` : ''}`;
const method = isEditMode ? 'PUT' : 'POST';
const body = {
...(isEditMode ? {} : { id: `vac-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` }),
position: formData.position.trim(),
department: formData.department.trim(),
status: formData.status,
salary: formData.salary.trim() || null,
description: formData.description.trim(),
requirements: formData.requirements.trim() || null,
conditions: formData.conditions.trim() || null,
responsibilities: formData.responsibilities.trim() || null,
postedDate: formData.postedDate,
closingDate: formData.closingDate.trim() || 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 {
// Сначала читаем как текст, затем пытаемся распарсить как JSON
const text = await response.text();
if (text) {
try {
const errorData = JSON.parse(text);
errorMessage = errorData.error || errorData.message || text;
} catch {
// Если не JSON, используем текст как есть
errorMessage = text;
}
}
} catch (textError) {
// Если не удалось прочитать ответ, используем дефолтное сообщение
console.error('Failed to read error response:', textError);
}
throw new Error(errorMessage);
}
const savedVacancy = await response.json();
onSave(savedVacancy);
} catch (error) {
console.error('Error saving vacancy:', error);
let errorMessage = 'Ошибка при сохранении вакансии';
if (error instanceof Error) {
errorMessage = error.message;
// Проверяем на сетевые ошибки
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
errorMessage = 'Ошибка сети. Проверьте подключение к серверу.';
}
}
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-3xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<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-2xl 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 */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Основная информация */}
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<Briefcase className="w-4 h-4"/> Основная информация
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Название должности *
</label>
<input
type="text"
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
placeholder="Слесарь-сантехник (4-5 разряд)"
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="text"
value={formData.department}
onChange={(e) => setFormData({ ...formData, department: 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"
required
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<DollarSign className="w-3 h-3"/> Зарплата
</label>
<input
type="text"
value={formData.salary}
onChange={(e) => setFormData({ ...formData, salary: e.target.value })}
placeholder="55 000 - 65 000 ₽"
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>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as 'urgent' | 'active' | 'paused' | 'closed' })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="active">Активна</option>
<option value="urgent">Срочно</option>
<option value="paused">Приостановлена</option>
<option value="closed">Закрыта</option>
</select>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<Calendar className="w-3 h-3"/> Дата публикации
</label>
<input
type="date"
value={formData.postedDate}
onChange={(e) => setFormData({ ...formData, postedDate: 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"
/>
</div>
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1 flex items-center gap-1">
<Calendar className="w-3 h-3"/> Дата закрытия
</label>
<input
type="date"
value={formData.closingDate}
onChange={(e) => setFormData({ ...formData, closingDate: 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"
/>
</div>
</div>
</div>
{/* Описание */}
<div>
<h4 className="text-sm font-black text-slate-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<FileText className="w-4 h-4"/> Описание вакансии
</h4>
<div className="space-y-4">
<div>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Описание *
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Краткое описание вакансии..."
rows={3}
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>
<textarea
value={formData.responsibilities}
onChange={(e) => setFormData({ ...formData, responsibilities: 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>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Требования к кандидату
</label>
<textarea
value={formData.requirements}
onChange={(e) => setFormData({ ...formData, requirements: 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>
<label className="block text-[10px] text-slate-400 font-bold uppercase mb-1">
Условия работы
</label>
<textarea
value={formData.conditions}
onChange={(e) => setFormData({ ...formData, conditions: e.target.value })}
placeholder="График работы, режим, дополнительные условия..."
rows={3}
className="w-full p-3 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
</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>
);
};

View File

@@ -0,0 +1,564 @@
import React, { useState, useEffect } from 'react';
import { X, Calendar, Clock, FileText, UserCheck, AlertCircle } from 'lucide-react';
import { Employee, EmployeeVacation, EmployeeSickLeave, EmployeeAbsence, User, UserRole } from '../../types';
import { authFetch } from '../../services/apiClient';
export type AbsenceType = 'vacation' | 'sick_leave' | 'day_off' | 'absence' | 'late' | 'early_leave';
interface WorkCalendarModalProps {
employee: Employee;
onClose: () => void;
onSave: () => void;
type?: AbsenceType;
currentUser?: User;
/** Список сотрудников для подстановки имени руководителя */
employees?: Employee[];
/** Сотрудники, от имени которых можно оформить запись (для смены имени в шапке) */
selectableEmployees?: Employee[];
/** Вызов при смене сотрудника в шапке модалки */
onEmployeeChange?: (employee: Employee) => void;
}
export const WorkCalendarModal: React.FC<WorkCalendarModalProps> = ({
employee,
onClose,
onSave,
type: initialType,
currentUser,
employees = [],
selectableEmployees,
onEmployeeChange,
}) => {
const [absenceType, setAbsenceType] = useState<AbsenceType>(initialType || 'vacation');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [reason, setReason] = useState('');
const [notes, setNotes] = useState('');
const [requiresApproval, setRequiresApproval] = useState(true);
const [approverSignature, setApproverSignature] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [vacationType, setVacationType] = useState<'annual' | 'unpaid' | 'study' | 'maternity' | 'other'>('annual');
const [sickLeaveNumber, setSickLeaveNumber] = useState('');
const [diagnosis, setDiagnosis] = useState('');
const [medicalInstitution, setMedicalInstitution] = useState('');
/** Предварительная дата выхода с больничного */
const [expectedReturnDate, setExpectedReturnDate] = useState('');
/** Флаг: пользователь вручную менял дату окончания отпуска (не перезаписывать +14) */
const [vacationEndManuallyChanged, setVacationEndManuallyChanged] = useState(false);
const absenceTypeLabels: Record<AbsenceType, { label: string; icon: React.ReactNode; color: string }> = {
vacation: { label: 'Отпуск', icon: '🏖️', color: 'bg-blue-50 text-blue-600 border-blue-200' },
sick_leave: { label: 'Больничный', icon: '🏥', color: 'bg-red-50 text-red-600 border-red-200' },
day_off: { label: 'Отгул', icon: '📅', color: 'bg-amber-50 text-amber-600 border-amber-200' },
absence: { label: 'Прогул', icon: '⚠️', color: 'bg-orange-50 text-orange-600 border-orange-200' },
late: { label: 'Опоздание', icon: '⏰', color: 'bg-purple-50 text-purple-600 border-purple-200' },
early_leave: { label: 'Ранний уход', icon: '🚪', color: 'bg-indigo-50 text-indigo-600 border-indigo-200' },
};
const vacationTypeLabels: Record<string, string> = {
annual: 'Ежегодный',
unpaid: 'Без сохранения зарплаты',
study: 'Учебный',
maternity: 'Декретный',
other: 'Другой',
};
const isSelf = currentUser && employee && currentUser.id === employee.id;
/** Типы, которые сотрудник не может оформить себе: только руководитель или HR подчинённым */
const onlyManagerOrHrTypes: AbsenceType[] = ['absence', 'late', 'early_leave'];
const allowedTypes = (Object.keys(absenceTypeLabels) as AbsenceType[]).filter(
(t) => !isSelf || !onlyManagerOrHrTypes.includes(t)
);
// Руководитель для согласования подтягивается автоматически; если руководителя нет — автовыбор HR
useEffect(() => {
if (!employee) return;
if (employee.managerId && employees?.length) {
const manager = employees.find((e) => e.id === employee.managerId);
if (manager?.name) {
setApproverSignature(manager.name);
return;
}
}
setApproverSignature('HR');
}, [employee?.id, employee?.managerId, employees]);
// Отпуск: конец по умолчанию +14 дней от начала, с возможностью изменить
useEffect(() => {
if (absenceType !== 'vacation' || !startDate || vacationEndManuallyChanged) return;
const start = new Date(startDate);
start.setDate(start.getDate() + 14);
setEndDate(start.toISOString().split('T')[0]);
}, [absenceType, startDate, vacationEndManuallyChanged]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
// Валидация
if (!startDate) {
setError('Укажите дату начала');
setLoading(false);
return;
}
if (absenceType === 'vacation' || absenceType === 'day_off' || absenceType === 'absence') {
if (!endDate) {
setError('Укажите дату окончания');
setLoading(false);
return;
}
if (new Date(endDate) < new Date(startDate)) {
setError('Дата окончания не может быть раньше даты начала');
setLoading(false);
return;
}
}
if (absenceType === 'sick_leave' && endDate && new Date(endDate) < new Date(startDate)) {
setError('Дата окончания не может быть раньше даты начала');
setLoading(false);
return;
}
if (absenceType === 'late' || absenceType === 'early_leave') {
if (!startTime) {
setError('Укажите время');
setLoading(false);
return;
}
}
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const baseUrl = (import.meta.env.DEV || !apiBaseUrl) ? '/api' : `${apiBaseUrl}`;
let response: Response;
if (absenceType === 'vacation') {
// Создание отпуска
const calculatedDays = Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1;
response = await authFetch(`${baseUrl}/employees/${employee.id}/vacations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startDate,
endDate,
daysCount: calculatedDays,
vacationType,
status: requiresApproval ? 'planned' : 'approved',
requiresApproval,
approvedBy: !requiresApproval ? 'Система' : undefined,
approvedAt: !requiresApproval ? new Date().toISOString() : undefined,
approvedSignature: !requiresApproval ? 'Автоматическое согласование' : approverSignature || undefined,
notes,
currentUserId: currentUser?.id,
currentUserRole: currentUser?.role,
}),
});
} else if (absenceType === 'sick_leave') {
// Создание больничного
const calculatedDays = endDate ? Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1 : undefined;
const sickLeaveUrl = `${baseUrl}/employees/${employee.id}/sick-leaves`;
response = await authFetch(sickLeaveUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startDate,
endDate: endDate || null,
expectedReturnDate: expectedReturnDate || null,
daysCount: calculatedDays,
sickLeaveNumber: sickLeaveNumber || null,
diagnosis: diagnosis || null,
medicalInstitution: medicalInstitution || null,
status: 'active',
requiresApproval,
approvedBy: !requiresApproval ? 'Система' : undefined,
approvedAt: !requiresApproval ? new Date().toISOString() : undefined,
approvedSignature: !requiresApproval ? 'Автоматическое согласование' : approverSignature || undefined,
notes,
currentUserId: currentUser?.id,
currentUserRole: currentUser?.role,
}),
});
} else {
// Создание отгула/прогула/опоздания/раннего ухода
let calculatedDays = 1.0;
if (endDate) {
calculatedDays = Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1;
} else if (startTime && endTime) {
// Для части дня рассчитываем долю дня
const start = new Date(`2000-01-01T${startTime}`);
const end = new Date(`2000-01-01T${endTime}`);
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
calculatedDays = hours / 8; // Предполагаем 8-часовой рабочий день
}
response = await authFetch(`${baseUrl}/employees/${employee.id}/absences`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
absenceType: absenceType === 'day_off' ? 'day_off' : absenceType === 'absence' ? 'absence' : absenceType === 'late' ? 'late' : 'early_leave',
startDate,
endDate: endDate || null,
startTime: startTime || null,
endTime: endTime || null,
daysCount: calculatedDays,
reason: reason || null,
requiresApproval,
status: requiresApproval ? 'pending' : 'approved',
approvedBy: !requiresApproval ? 'Система' : undefined,
approvedAt: !requiresApproval ? new Date().toISOString() : undefined,
approvedSignature: !requiresApproval ? 'Автоматическое согласование' : approverSignature || undefined,
notes: notes || null,
currentUserId: currentUser?.id, // ID текущего пользователя для проверки прав
currentUserRole: currentUser?.role, // Роль текущего пользователя для проверки прав
}),
});
}
if (response.ok) {
onSave();
onClose();
} else {
const errorData = await response.json();
setError(errorData.error || 'Ошибка при создании записи');
}
} catch (err) {
console.error('Error creating absence:', err);
setError('Ошибка при создании записи');
} finally {
setLoading(false);
}
};
const showDateRange = absenceType === 'vacation' || absenceType === 'sick_leave' || absenceType === 'day_off' || absenceType === 'absence';
const showTimeFields = absenceType === 'late' || absenceType === 'early_leave';
const showVacationFields = absenceType === 'vacation';
const showSickLeaveFields = absenceType === 'sick_leave';
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()}
>
{/* Header */}
<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-xl font-bold text-slate-800">Рабочий календарь</h3>
{selectableEmployees && selectableEmployees.length > 0 && onEmployeeChange ? (
<select
value={employee.id}
onChange={(e) => {
const next = selectableEmployees.find((emp) => emp.id === e.target.value);
if (next) onEmployeeChange(next);
}}
className="mt-1 block text-sm text-slate-500 bg-transparent border-0 border-b border-transparent hover:border-slate-300 p-0 pr-5 -ml-0.5 cursor-pointer focus:ring-0 focus:outline-none focus:border-primary-500 hover:text-slate-700 font-normal appearance-none bg-no-repeat bg-[length:10px] bg-[right_0_top_55%]"
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'%2394a3b8\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\'%3E%3C/path%3E%3C/svg%3E")' }}
>
{selectableEmployees.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.name}
</option>
))}
</select>
) : (
<p className="text-sm text-slate-500 mt-1">{employee.name}</p>
)}
</div>
<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>
{/* Content */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0"/>
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* Тип пропуска (прогул/опоздание/ранний уход только для подчинённых — руководитель или HR) */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-3">
Тип пропуска
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{allowedTypes.map((type) => {
const typeInfo = absenceTypeLabels[type];
return (
<button
key={type}
type="button"
onClick={() => setAbsenceType(type)}
className={`p-4 rounded-xl border-2 transition-all text-left ${
absenceType === type
? `${typeInfo.color} border-current`
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-1">{typeInfo.icon}</div>
<div className="text-xs font-bold uppercase tracking-wider">{typeInfo.label}</div>
</button>
);
})}
</div>
</div>
{/* Тип отпуска (только для отпусков) */}
{showVacationFields && (
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Тип отпуска
</label>
<select
value={vacationType}
onChange={(e) => setVacationType(e.target.value as any)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
{Object.entries(vacationTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
)}
{/* Даты */}
{showDateRange && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Дата начала
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Дата окончания
</label>
<input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
if (absenceType === 'vacation') setVacationEndManuallyChanged(true);
}}
required={absenceType !== 'sick_leave'}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
{absenceType === 'vacation' && (
<p className="text-xs text-slate-500 mt-1">По умолчанию +14 дней от начала, можно изменить</p>
)}
{absenceType === 'sick_leave' && (
<p className="text-xs text-slate-500 mt-1">Заполняется при выходе с больничного</p>
)}
</div>
</div>
)}
{/* Время (для опозданий и ранних уходов) */}
{showTimeFields && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Дата
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Clock className="w-4 h-4"/> Время начала
</label>
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Clock className="w-4 h-4"/> Время окончания
</label>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
)}
{/* Дополнительные поля для больничных */}
{showSickLeaveFields && (
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Предварительная дата выхода
</label>
<input
type="date"
value={expectedReturnDate}
onChange={(e) => setExpectedReturnDate(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<p className="text-xs text-slate-500 mt-1">При выходе нажмите «Выход с больничного» и укажите номер листа</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Номер больничного листа
</label>
<input
type="text"
value={sickLeaveNumber}
onChange={(e) => setSickLeaveNumber(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Обязателен при выходе с больничного"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Медицинское учреждение
</label>
<input
type="text"
value={medicalInstitution}
onChange={(e) => setMedicalInstitution(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Необязательно"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Диагноз
</label>
<input
type="text"
value={diagnosis}
onChange={(e) => setDiagnosis(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Необязательно"
/>
</div>
</div>
)}
{/* Причина (для отгулов и прогулов) */}
{(absenceType === 'day_off' || absenceType === 'absence' || absenceType === 'late' || absenceType === 'early_leave') && (
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Причина
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="Укажите причину отсутствия"
/>
</div>
)}
{/* Согласование */}
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-4">
<label className="flex items-center gap-2 text-sm font-bold text-slate-700">
<UserCheck className="w-4 h-4"/> Требуется согласование с руководителем
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={requiresApproval}
onChange={(e) => setRequiresApproval(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-300 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
{requiresApproval && (
<div className="mt-4">
<label className="block text-sm font-bold text-slate-700 mb-2">
Подпись руководителя
</label>
<input
type="text"
value={approverSignature}
onChange={(e) => setApproverSignature(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Введите ФИО руководителя для согласования"
/>
<p className="text-xs text-slate-500 mt-1">
Руководитель должен будет подтвердить запись после создания
</p>
</div>
)}
</div>
{/* Примечания */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<FileText className="w-4 h-4"/> Примечания
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="Дополнительная информация (необязательно)"
/>
</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-sm font-bold hover:bg-slate-100 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-3 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary-500/20"
>
{loading ? 'Создание...' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
};

1074
components/hr/WorkCalendarView.tsx Executable file

File diff suppressed because it is too large Load Diff