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

433 lines
24 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { 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>
);
};