Initial commit MKD fixes

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

View File

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