Initial commit MKD fixes
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user