Files
mkd/components/hr/WorkCalendarModal.tsx
2026-02-04 00:17:04 +05:00

565 lines
27 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { X, Calendar, Clock, FileText, UserCheck, AlertCircle } from 'lucide-react';
import { Employee, EmployeeVacation, EmployeeSickLeave, EmployeeAbsence, User, UserRole } from '../../types';
import { authFetch } from '../../services/apiClient';
export type AbsenceType = 'vacation' | 'sick_leave' | 'day_off' | 'absence' | 'late' | 'early_leave';
interface WorkCalendarModalProps {
employee: Employee;
onClose: () => void;
onSave: () => void;
type?: AbsenceType;
currentUser?: User;
/** Список сотрудников для подстановки имени руководителя */
employees?: Employee[];
/** Сотрудники, от имени которых можно оформить запись (для смены имени в шапке) */
selectableEmployees?: Employee[];
/** Вызов при смене сотрудника в шапке модалки */
onEmployeeChange?: (employee: Employee) => void;
}
export const WorkCalendarModal: React.FC<WorkCalendarModalProps> = ({
employee,
onClose,
onSave,
type: initialType,
currentUser,
employees = [],
selectableEmployees,
onEmployeeChange,
}) => {
const [absenceType, setAbsenceType] = useState<AbsenceType>(initialType || 'vacation');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [reason, setReason] = useState('');
const [notes, setNotes] = useState('');
const [requiresApproval, setRequiresApproval] = useState(true);
const [approverSignature, setApproverSignature] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [vacationType, setVacationType] = useState<'annual' | 'unpaid' | 'study' | 'maternity' | 'other'>('annual');
const [sickLeaveNumber, setSickLeaveNumber] = useState('');
const [diagnosis, setDiagnosis] = useState('');
const [medicalInstitution, setMedicalInstitution] = useState('');
/** Предварительная дата выхода с больничного */
const [expectedReturnDate, setExpectedReturnDate] = useState('');
/** Флаг: пользователь вручную менял дату окончания отпуска (не перезаписывать +14) */
const [vacationEndManuallyChanged, setVacationEndManuallyChanged] = useState(false);
const absenceTypeLabels: Record<AbsenceType, { label: string; icon: React.ReactNode; color: string }> = {
vacation: { label: 'Отпуск', icon: '🏖️', color: 'bg-blue-50 text-blue-600 border-blue-200' },
sick_leave: { label: 'Больничный', icon: '🏥', color: 'bg-red-50 text-red-600 border-red-200' },
day_off: { label: 'Отгул', icon: '📅', color: 'bg-amber-50 text-amber-600 border-amber-200' },
absence: { label: 'Прогул', icon: '⚠️', color: 'bg-orange-50 text-orange-600 border-orange-200' },
late: { label: 'Опоздание', icon: '⏰', color: 'bg-purple-50 text-purple-600 border-purple-200' },
early_leave: { label: 'Ранний уход', icon: '🚪', color: 'bg-indigo-50 text-indigo-600 border-indigo-200' },
};
const vacationTypeLabels: Record<string, string> = {
annual: 'Ежегодный',
unpaid: 'Без сохранения зарплаты',
study: 'Учебный',
maternity: 'Декретный',
other: 'Другой',
};
const isSelf = currentUser && employee && currentUser.id === employee.id;
/** Типы, которые сотрудник не может оформить себе: только руководитель или HR подчинённым */
const onlyManagerOrHrTypes: AbsenceType[] = ['absence', 'late', 'early_leave'];
const allowedTypes = (Object.keys(absenceTypeLabels) as AbsenceType[]).filter(
(t) => !isSelf || !onlyManagerOrHrTypes.includes(t)
);
// Руководитель для согласования подтягивается автоматически; если руководителя нет — автовыбор HR
useEffect(() => {
if (!employee) return;
if (employee.managerId && employees?.length) {
const manager = employees.find((e) => e.id === employee.managerId);
if (manager?.name) {
setApproverSignature(manager.name);
return;
}
}
setApproverSignature('HR');
}, [employee?.id, employee?.managerId, employees]);
// Отпуск: конец по умолчанию +14 дней от начала, с возможностью изменить
useEffect(() => {
if (absenceType !== 'vacation' || !startDate || vacationEndManuallyChanged) return;
const start = new Date(startDate);
start.setDate(start.getDate() + 14);
setEndDate(start.toISOString().split('T')[0]);
}, [absenceType, startDate, vacationEndManuallyChanged]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
// Валидация
if (!startDate) {
setError('Укажите дату начала');
setLoading(false);
return;
}
if (absenceType === 'vacation' || absenceType === 'day_off' || absenceType === 'absence') {
if (!endDate) {
setError('Укажите дату окончания');
setLoading(false);
return;
}
if (new Date(endDate) < new Date(startDate)) {
setError('Дата окончания не может быть раньше даты начала');
setLoading(false);
return;
}
}
if (absenceType === 'sick_leave' && endDate && new Date(endDate) < new Date(startDate)) {
setError('Дата окончания не может быть раньше даты начала');
setLoading(false);
return;
}
if (absenceType === 'late' || absenceType === 'early_leave') {
if (!startTime) {
setError('Укажите время');
setLoading(false);
return;
}
}
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const baseUrl = (import.meta.env.DEV || !apiBaseUrl) ? '/api' : `${apiBaseUrl}`;
let response: Response;
if (absenceType === 'vacation') {
// Создание отпуска
const calculatedDays = Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1;
response = await authFetch(`${baseUrl}/employees/${employee.id}/vacations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startDate,
endDate,
daysCount: calculatedDays,
vacationType,
status: requiresApproval ? 'planned' : 'approved',
requiresApproval,
approvedBy: !requiresApproval ? 'Система' : undefined,
approvedAt: !requiresApproval ? new Date().toISOString() : undefined,
approvedSignature: !requiresApproval ? 'Автоматическое согласование' : approverSignature || undefined,
notes,
currentUserId: currentUser?.id,
currentUserRole: currentUser?.role,
}),
});
} else if (absenceType === 'sick_leave') {
// Создание больничного
const calculatedDays = endDate ? Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1 : undefined;
const sickLeaveUrl = `${baseUrl}/employees/${employee.id}/sick-leaves`;
response = await authFetch(sickLeaveUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startDate,
endDate: endDate || null,
expectedReturnDate: expectedReturnDate || null,
daysCount: calculatedDays,
sickLeaveNumber: sickLeaveNumber || null,
diagnosis: diagnosis || null,
medicalInstitution: medicalInstitution || null,
status: 'active',
requiresApproval,
approvedBy: !requiresApproval ? 'Система' : undefined,
approvedAt: !requiresApproval ? new Date().toISOString() : undefined,
approvedSignature: !requiresApproval ? 'Автоматическое согласование' : approverSignature || undefined,
notes,
currentUserId: currentUser?.id,
currentUserRole: currentUser?.role,
}),
});
} else {
// Создание отгула/прогула/опоздания/раннего ухода
let calculatedDays = 1.0;
if (endDate) {
calculatedDays = Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1;
} else if (startTime && endTime) {
// Для части дня рассчитываем долю дня
const start = new Date(`2000-01-01T${startTime}`);
const end = new Date(`2000-01-01T${endTime}`);
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
calculatedDays = hours / 8; // Предполагаем 8-часовой рабочий день
}
response = await authFetch(`${baseUrl}/employees/${employee.id}/absences`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
absenceType: absenceType === 'day_off' ? 'day_off' : absenceType === 'absence' ? 'absence' : absenceType === 'late' ? 'late' : 'early_leave',
startDate,
endDate: endDate || null,
startTime: startTime || null,
endTime: endTime || null,
daysCount: calculatedDays,
reason: reason || null,
requiresApproval,
status: requiresApproval ? 'pending' : 'approved',
approvedBy: !requiresApproval ? 'Система' : undefined,
approvedAt: !requiresApproval ? new Date().toISOString() : undefined,
approvedSignature: !requiresApproval ? 'Автоматическое согласование' : approverSignature || undefined,
notes: notes || null,
currentUserId: currentUser?.id, // ID текущего пользователя для проверки прав
currentUserRole: currentUser?.role, // Роль текущего пользователя для проверки прав
}),
});
}
if (response.ok) {
onSave();
onClose();
} else {
const errorData = await response.json();
setError(errorData.error || 'Ошибка при создании записи');
}
} catch (err) {
console.error('Error creating absence:', err);
setError('Ошибка при создании записи');
} finally {
setLoading(false);
}
};
const showDateRange = absenceType === 'vacation' || absenceType === 'sick_leave' || absenceType === 'day_off' || absenceType === 'absence';
const showTimeFields = absenceType === 'late' || absenceType === 'early_leave';
const showVacationFields = absenceType === 'vacation';
const showSickLeaveFields = absenceType === 'sick_leave';
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()}
>
{/* 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">
<div>
<h3 className="text-xl font-bold text-slate-800">Рабочий календарь</h3>
{selectableEmployees && selectableEmployees.length > 0 && onEmployeeChange ? (
<select
value={employee.id}
onChange={(e) => {
const next = selectableEmployees.find((emp) => emp.id === e.target.value);
if (next) onEmployeeChange(next);
}}
className="mt-1 block text-sm text-slate-500 bg-transparent border-0 border-b border-transparent hover:border-slate-300 p-0 pr-5 -ml-0.5 cursor-pointer focus:ring-0 focus:outline-none focus:border-primary-500 hover:text-slate-700 font-normal appearance-none bg-no-repeat bg-[length:10px] bg-[right_0_top_55%]"
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'%2394a3b8\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\'%3E%3C/path%3E%3C/svg%3E")' }}
>
{selectableEmployees.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.name}
</option>
))}
</select>
) : (
<p className="text-sm text-slate-500 mt-1">{employee.name}</p>
)}
</div>
<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>
{/* Content */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0"/>
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* Тип пропуска (прогул/опоздание/ранний уход только для подчинённых — руководитель или HR) */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-3">
Тип пропуска
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{allowedTypes.map((type) => {
const typeInfo = absenceTypeLabels[type];
return (
<button
key={type}
type="button"
onClick={() => setAbsenceType(type)}
className={`p-4 rounded-xl border-2 transition-all text-left ${
absenceType === type
? `${typeInfo.color} border-current`
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-1">{typeInfo.icon}</div>
<div className="text-xs font-bold uppercase tracking-wider">{typeInfo.label}</div>
</button>
);
})}
</div>
</div>
{/* Тип отпуска (только для отпусков) */}
{showVacationFields && (
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Тип отпуска
</label>
<select
value={vacationType}
onChange={(e) => setVacationType(e.target.value as any)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
{Object.entries(vacationTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
)}
{/* Даты */}
{showDateRange && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Дата начала
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Дата окончания
</label>
<input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
if (absenceType === 'vacation') setVacationEndManuallyChanged(true);
}}
required={absenceType !== 'sick_leave'}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
{absenceType === 'vacation' && (
<p className="text-xs text-slate-500 mt-1">По умолчанию +14 дней от начала, можно изменить</p>
)}
{absenceType === 'sick_leave' && (
<p className="text-xs text-slate-500 mt-1">Заполняется при выходе с больничного</p>
)}
</div>
</div>
)}
{/* Время (для опозданий и ранних уходов) */}
{showTimeFields && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Дата
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Clock className="w-4 h-4"/> Время начала
</label>
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
required
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Clock className="w-4 h-4"/> Время окончания
</label>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
)}
{/* Дополнительные поля для больничных */}
{showSickLeaveFields && (
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4"/> Предварительная дата выхода
</label>
<input
type="date"
value={expectedReturnDate}
onChange={(e) => setExpectedReturnDate(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<p className="text-xs text-slate-500 mt-1">При выходе нажмите «Выход с больничного» и укажите номер листа</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Номер больничного листа
</label>
<input
type="text"
value={sickLeaveNumber}
onChange={(e) => setSickLeaveNumber(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Обязателен при выходе с больничного"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Медицинское учреждение
</label>
<input
type="text"
value={medicalInstitution}
onChange={(e) => setMedicalInstitution(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Необязательно"
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Диагноз
</label>
<input
type="text"
value={diagnosis}
onChange={(e) => setDiagnosis(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Необязательно"
/>
</div>
</div>
)}
{/* Причина (для отгулов и прогулов) */}
{(absenceType === 'day_off' || absenceType === 'absence' || absenceType === 'late' || absenceType === 'early_leave') && (
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Причина
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="Укажите причину отсутствия"
/>
</div>
)}
{/* Согласование */}
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-4">
<label className="flex items-center gap-2 text-sm font-bold text-slate-700">
<UserCheck className="w-4 h-4"/> Требуется согласование с руководителем
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={requiresApproval}
onChange={(e) => setRequiresApproval(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-300 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary-500 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
{requiresApproval && (
<div className="mt-4">
<label className="block text-sm font-bold text-slate-700 mb-2">
Подпись руководителя
</label>
<input
type="text"
value={approverSignature}
onChange={(e) => setApproverSignature(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Введите ФИО руководителя для согласования"
/>
<p className="text-xs text-slate-500 mt-1">
Руководитель должен будет подтвердить запись после создания
</p>
</div>
)}
</div>
{/* Примечания */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<FileText className="w-4 h-4"/> Примечания
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-4 py-3 rounded-xl border border-slate-200 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="Дополнительная информация (необязательно)"
/>
</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-sm font-bold hover:bg-slate-100 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-3 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary-500/20"
>
{loading ? 'Создание...' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
};