Initial commit MKD fixes
This commit is contained in:
133
components/hr/AssignTrainingModal.tsx
Executable file
133
components/hr/AssignTrainingModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
620
components/hr/CandidateEventsTimeline.tsx
Executable file
620
components/hr/CandidateEventsTimeline.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
466
components/hr/CandidateFormModal.tsx
Executable file
466
components/hr/CandidateFormModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
499
components/hr/CandidatesRegistry.tsx
Executable file
499
components/hr/CandidatesRegistry.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
1251
components/hr/EmployeeCardModal.tsx
Executable file
1251
components/hr/EmployeeCardModal.tsx
Executable file
File diff suppressed because it is too large
Load Diff
1188
components/hr/EmployeeFormModal.tsx
Executable file
1188
components/hr/EmployeeFormModal.tsx
Executable file
File diff suppressed because it is too large
Load Diff
413
components/hr/EmployeeRegistry.tsx
Executable file
413
components/hr/EmployeeRegistry.tsx
Executable 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
445
components/hr/HRSummary.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
|
||||
65
components/hr/HiringPipeline.tsx
Executable file
65
components/hr/HiringPipeline.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
357
components/hr/OrganizationalStructure.tsx
Executable file
357
components/hr/OrganizationalStructure.tsx
Executable 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
69
components/hr/PayrollModule.tsx
Executable 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
400
components/hr/ProgramModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
80
components/hr/SafetyBriefing.tsx
Executable file
80
components/hr/SafetyBriefing.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
264
components/hr/TrainingManagementModal.tsx
Executable file
264
components/hr/TrainingManagementModal.tsx
Executable 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
778
components/hr/TrainingModule.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
432
components/hr/VacanciesRegistry.tsx
Executable file
432
components/hr/VacanciesRegistry.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
291
components/hr/VacancyFormModal.tsx
Executable file
291
components/hr/VacancyFormModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
564
components/hr/WorkCalendarModal.tsx
Executable file
564
components/hr/WorkCalendarModal.tsx
Executable 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
1074
components/hr/WorkCalendarView.tsx
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user