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

400
components/hr/ProgramModal.tsx Executable file
View 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>
);
};