433 lines
24 KiB
TypeScript
Executable File
433 lines
24 KiB
TypeScript
Executable File
|
||
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>
|
||
);
|
||
};
|