292 lines
16 KiB
TypeScript
292 lines
16 KiB
TypeScript
|
|
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|