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

1075 lines
53 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, useMemo } from 'react';
import { Calendar, Plus, Filter, Search, X, CheckCircle, XCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import { Employee, EmployeeVacation, EmployeeSickLeave, EmployeeAbsence, User } from '../../types';
import { WorkCalendarModal, AbsenceType } from './WorkCalendarModal';
import { authFetch } from '../../services/apiClient';
interface WorkCalendarViewProps {
currentUser?: User; // Текущий пользователь для проверки прав доступа
}
export const WorkCalendarView: React.FC<WorkCalendarViewProps> = ({ currentUser }) => {
const [employees, setEmployees] = useState<Employee[]>([]);
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalType, setModalType] = useState<AbsenceType | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [currentDate, setCurrentDate] = useState<Date>(new Date());
const [daysToShow, setDaysToShow] = useState<number>(7);
const [sickLeaveCloseModal, setSickLeaveCloseModal] = useState<{ employeeId: string; sickLeaveId: number; employee: Employee } | null>(null);
const [sickLeaveExtendModal, setSickLeaveExtendModal] = useState<{ employeeId: string; sickLeaveId: number; employee: Employee } | null>(null);
const [closeEndDate, setCloseEndDate] = useState('');
const [closeSickLeaveNumber, setCloseSickLeaveNumber] = useState('');
const [extendExpectedReturnDate, setExtendExpectedReturnDate] = useState('');
const [sickLeaveActionLoading, setSickLeaveActionLoading] = useState(false);
const [sickLeaveActionError, setSickLeaveActionError] = useState('');
const [vacationApprovingKey, setVacationApprovingKey] = useState<string | null>(null);
const [vacationRejectModal, setVacationRejectModal] = useState<{ employeeId: string; vacationId: number; employee: Employee } | null>(null);
const [vacationRejectReason, setVacationRejectReason] = useState('');
const [absenceRejectModal, setAbsenceRejectModal] = useState<{ employeeId: string; absenceId: number; employee: Employee } | null>(null);
const [absenceRejectReason, setAbsenceRejectReason] = useState('');
const [absenceApprovingKey, setAbsenceApprovingKey] = useState<string | null>(null);
useEffect(() => {
fetchEmployees();
}, []);
const fetchEmployees = async () => {
try {
setLoading(true);
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const apiUrl = (import.meta.env.DEV || !apiBaseUrl) ? '/api/employees' : `${apiBaseUrl}/employees`;
const response = await authFetch(apiUrl);
if (response.ok) {
const data = await response.json();
setEmployees(data);
}
} catch (error) {
console.error('Error fetching employees:', error);
} finally {
setLoading(false);
}
};
const handleOpenModal = (employee: Employee, type?: AbsenceType) => {
setSelectedEmployee(employee);
setModalType(type);
setIsModalOpen(true);
};
// В рабочем календаре только действующие сотрудники (без уволенных)
const activeEmployees = useMemo(
() => employees.filter((e) => e.status !== 'inactive'),
[employees]
);
const isHr = currentUser?.role === 'HR_MANAGER' || currentUser?.role === 'DIRECTOR';
const subordinates = useMemo(
() => (currentUser?.id ? activeEmployees.filter((e) => e.managerId === currentUser.id) : []),
[activeEmployees, currentUser?.id]
);
const selfEmployee = useMemo(
() => (currentUser?.id ? activeEmployees.find((e) => e.id === currentUser.id) : null),
[activeEmployees, currentUser?.id]
);
const actAsOptions = useMemo(() => {
if (isHr) return activeEmployees;
const list = selfEmployee ? [selfEmployee, ...subordinates] : subordinates;
return list;
}, [isHr, activeEmployees, selfEmployee, subordinates]);
const employeesICanApprove = useMemo(() => (isHr ? activeEmployees : subordinates), [isHr, activeEmployees, subordinates]);
const pendingVacations = useMemo(() => {
const list: Array<{ id: number; employee: Employee; startDate: string; endDate: string; daysCount: number }> = [];
employeesICanApprove.forEach((emp) => {
emp.hrData?.vacations?.forEach((v) => {
if (v.status === 'planned') list.push({ id: v.id, employee: emp, startDate: v.startDate, endDate: v.endDate, daysCount: v.daysCount });
});
});
return list;
}, [employeesICanApprove]);
const pendingDayOffs = useMemo(() => {
const list: Array<{ id: number; employee: Employee; startDate: string; endDate?: string; reason?: string }> = [];
employeesICanApprove.forEach((emp) => {
emp.hrData?.absences?.forEach((a) => {
if (a.absenceType === 'day_off' && a.status === 'pending')
list.push({ id: a.id, employee: emp, startDate: a.startDate, endDate: a.endDate, reason: a.reason });
});
});
return list;
}, [employeesICanApprove]);
const recentSickLeaves = useMemo(() => {
const list: Array<{ id: number; employee: Employee; startDate: string; endDate?: string; expectedReturnDate?: string }> = [];
employeesICanApprove.forEach((emp) => {
emp.hrData?.sickLeaves?.forEach((s) => {
if (s.status === 'active') list.push({ id: s.id, employee: emp, startDate: s.startDate, endDate: s.endDate, expectedReturnDate: s.expectedReturnDate });
});
});
return list;
}, [employeesICanApprove]);
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedEmployee(null);
setModalType(undefined);
};
const handleSave = () => {
fetchEmployees();
};
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const baseUrl = (import.meta.env.DEV || !apiBaseUrl) ? '/api' : `${apiBaseUrl}`;
const handleCloseSickLeave = async () => {
if (!sickLeaveCloseModal) return;
if (!closeSickLeaveNumber.trim()) {
setSickLeaveActionError('Номер больничного листа обязателен при выходе с больничного');
return;
}
setSickLeaveActionError('');
setSickLeaveActionLoading(true);
try {
const res = await authFetch(
`${baseUrl}/employees/${sickLeaveCloseModal.employeeId}/sick-leaves/${sickLeaveCloseModal.sickLeaveId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'closed',
endDate: closeEndDate || new Date().toISOString().split('T')[0],
sickLeaveNumber: closeSickLeaveNumber.trim(),
}),
}
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Ошибка при закрытии больничного');
}
setSickLeaveCloseModal(null);
setCloseEndDate('');
setCloseSickLeaveNumber('');
fetchEmployees();
} catch (err) {
setSickLeaveActionError(err instanceof Error ? err.message : 'Ошибка при закрытии больничного');
} finally {
setSickLeaveActionLoading(false);
}
};
const handleExtendSickLeave = async () => {
if (!sickLeaveExtendModal || !extendExpectedReturnDate) return;
setSickLeaveActionError('');
setSickLeaveActionLoading(true);
try {
const res = await authFetch(
`${baseUrl}/employees/${sickLeaveExtendModal.employeeId}/sick-leaves/${sickLeaveExtendModal.sickLeaveId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedReturnDate: extendExpectedReturnDate }),
}
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Ошибка при переносе даты выхода');
}
setSickLeaveExtendModal(null);
setExtendExpectedReturnDate('');
fetchEmployees();
} catch (err) {
setSickLeaveActionError(err instanceof Error ? err.message : 'Ошибка при переносе даты выхода');
} finally {
setSickLeaveActionLoading(false);
}
};
const canApproveVacation = (absence: { type: string; status: string; employee: Employee }): boolean => {
if (absence.type !== 'vacation' || absence.status !== 'planned') return false;
if (!currentUser) return false;
const isHr = currentUser.role === 'HR_MANAGER' || currentUser.role === 'DIRECTOR';
const isManager = absence.employee.managerId === currentUser.id;
return isHr || isManager;
};
const handleApproveVacation = async (employeeId: string, vacationId: number) => {
const key = `${employeeId}-${vacationId}`;
setVacationApprovingKey(key);
try {
const res = await authFetch(
`${baseUrl}/employees/${employeeId}/vacations/${vacationId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved', approvedBy: currentUser?.name || 'Руководитель' }),
}
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Ошибка при утверждении отпуска');
}
fetchEmployees();
} catch (err) {
setSickLeaveActionError(err instanceof Error ? err.message : 'Ошибка при утверждении отпуска');
} finally {
setVacationApprovingKey(null);
}
};
const handleRejectVacation = async () => {
if (!vacationRejectModal) return;
setSickLeaveActionError('');
setSickLeaveActionLoading(true);
try {
const res = await authFetch(
`${baseUrl}/employees/${vacationRejectModal.employeeId}/vacations/${vacationRejectModal.vacationId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'rejected',
rejectedBy: currentUser?.name || 'Руководитель',
rejectionReason: vacationRejectReason.trim() || undefined,
}),
}
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Ошибка при отклонении отпуска');
}
setVacationRejectModal(null);
setVacationRejectReason('');
fetchEmployees();
} catch (err) {
setSickLeaveActionError(err instanceof Error ? err.message : 'Ошибка при отклонении отпуска');
} finally {
setSickLeaveActionLoading(false);
}
};
const canApproveDayOff = (absence: { type: string; status: string; employee: Employee }): boolean => {
if (absence.type !== 'day_off' || absence.status !== 'pending') return false;
if (!currentUser) return false;
const isHrRole = currentUser.role === 'HR_MANAGER' || currentUser.role === 'DIRECTOR';
const isManager = absence.employee.managerId === currentUser.id;
return isHrRole || isManager;
};
const handleApproveAbsence = async (employeeId: string, absenceId: number) => {
const key = `abs-${employeeId}-${absenceId}`;
setAbsenceApprovingKey(key);
try {
const res = await authFetch(
`${baseUrl}/employees/${employeeId}/absences/${absenceId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved', approvedBy: currentUser?.name || 'Руководитель' }),
}
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Ошибка при утверждении отгула');
}
fetchEmployees();
} catch (err) {
setSickLeaveActionError(err instanceof Error ? err.message : 'Ошибка при утверждении отгула');
} finally {
setAbsenceApprovingKey(null);
}
};
const handleRejectAbsence = async () => {
if (!absenceRejectModal) return;
setSickLeaveActionError('');
setSickLeaveActionLoading(true);
try {
const res = await authFetch(
`${baseUrl}/employees/${absenceRejectModal.employeeId}/absences/${absenceRejectModal.absenceId}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'rejected',
rejectedBy: currentUser?.name || 'Руководитель',
rejectionReason: absenceRejectReason.trim() || undefined,
}),
}
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Ошибка при отклонении отгула');
}
setAbsenceRejectModal(null);
setAbsenceRejectReason('');
fetchEmployees();
} catch (err) {
setSickLeaveActionError(err instanceof Error ? err.message : 'Ошибка при отклонении отгула');
} finally {
setSickLeaveActionLoading(false);
}
};
const filteredEmployees = useMemo(() => {
return activeEmployees.filter(emp => {
const matchesSearch = emp.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
emp.position.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
});
}, [activeEmployees, searchQuery]);
const getAbsenceTypeLabel = (type: string) => {
const labels: Record<string, { label: string; color: string; icon: string }> = {
vacation: { label: 'Отпуск', color: 'bg-blue-50 text-blue-600 border-blue-200', icon: '🏖️' },
sick_leave: { label: 'Больничный', color: 'bg-red-50 text-red-600 border-red-200', icon: '🏥' },
day_off: { label: 'Отгул', color: 'bg-amber-50 text-amber-600 border-amber-200', icon: '📅' },
absence: { label: 'Прогул', color: 'bg-orange-50 text-orange-600 border-orange-200', icon: '⚠️' },
late: { label: 'Опоздание', color: 'bg-purple-50 text-purple-600 border-purple-200', icon: '⏰' },
early_leave: { label: 'Ранний уход', color: 'bg-indigo-50 text-indigo-600 border-indigo-200', icon: '🚪' },
};
return labels[type] || { label: type, color: 'bg-slate-50 text-slate-600 border-slate-200', icon: '📌' };
};
const getStatusLabel = (status: string) => {
const labels: Record<string, { label: string; color: string }> = {
pending: { label: 'На согласовании', color: 'bg-amber-50 text-amber-600' },
approved: { label: 'Утверждено', color: 'bg-emerald-50 text-emerald-600' },
rejected: { label: 'Отклонено', color: 'bg-red-50 text-red-600' },
canceled: { label: 'Отменено', color: 'bg-slate-50 text-slate-600' },
planned: { label: 'Запланировано', color: 'bg-blue-50 text-blue-600' },
active: { label: 'Активно', color: 'bg-emerald-50 text-emerald-600' },
completed: { label: 'Завершено', color: 'bg-slate-50 text-slate-600' },
closed: { label: 'Закрыт', color: 'bg-slate-50 text-slate-600' },
};
return labels[status] || { label: status, color: 'bg-slate-50 text-slate-600' };
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
};
const formatDateShort = (date: Date) => {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
};
const formatDateWeekday = (date: Date) => {
return date.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' });
};
// Генерация списка дат для отображения
const dateList = useMemo(() => {
const dates: Date[] = [];
const startDate = new Date(currentDate);
// Если показываем неделю (7 дней), начинаем с понедельника
if (daysToShow === 7) {
const dayOfWeek = startDate.getDay(); // 0 = воскресенье, 1 = понедельник, ..., 6 = суббота
// Вычисляем смещение до понедельника (1 = понедельник)
// Если день = 0 (воскресенье), то нужно отступить 6 дней назад
// Если день = 1 (понедельник), то смещение = 0
// Если день = 2 (вторник), то смещение = -1
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
startDate.setDate(startDate.getDate() + daysToMonday);
} else {
// Для других периодов используем текущую логику
startDate.setDate(startDate.getDate() - Math.floor(daysToShow / 2));
}
for (let i = 0; i < daysToShow; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
dates.push(date);
}
return dates;
}, [currentDate, daysToShow]);
// Навигация по датам
const handlePreviousDays = () => {
const newDate = new Date(currentDate);
if (daysToShow === 7) {
// Для недели переходим на понедельник предыдущей недели
const dayOfWeek = newDate.getDay();
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
newDate.setDate(newDate.getDate() + daysToMonday - 7);
} else {
newDate.setDate(newDate.getDate() - daysToShow);
}
setCurrentDate(newDate);
};
const handleNextDays = () => {
const newDate = new Date(currentDate);
if (daysToShow === 7) {
// Для недели переходим на понедельник следующей недели
const dayOfWeek = newDate.getDay();
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
newDate.setDate(newDate.getDate() + daysToMonday + 7);
} else {
newDate.setDate(newDate.getDate() + daysToShow);
}
setCurrentDate(newDate);
};
const handleToday = () => {
setCurrentDate(new Date());
};
const getAllAbsences = (employee: Employee) => {
const absences: Array<{
id: string | number;
type: string;
startDate: string;
endDate?: string;
startTime?: string;
endTime?: string;
daysCount: number;
status: string;
reason?: string;
approvedBy?: string;
createdAt: string;
employee: Employee;
}> = [];
// Отпуска
if (employee.hrData?.vacations) {
employee.hrData.vacations.forEach(v => {
absences.push({
id: v.id,
type: 'vacation',
startDate: v.startDate,
endDate: v.endDate,
daysCount: v.daysCount,
status: v.status,
approvedBy: v.approvedBy,
createdAt: v.createdAt,
employee: employee,
});
});
}
// Больничные
if (employee.hrData?.sickLeaves) {
employee.hrData.sickLeaves.forEach(s => {
absences.push({
id: s.id,
type: 'sick_leave',
startDate: s.startDate,
endDate: s.endDate,
daysCount: s.daysCount || 0,
status: s.status,
createdAt: s.createdAt,
employee: employee,
sickLeaveNumber: s.sickLeaveNumber,
expectedReturnDate: s.expectedReturnDate,
});
});
}
// Отгулы и прогулы
if (employee.hrData?.absences) {
employee.hrData.absences.forEach(a => {
absences.push({
id: a.id,
type: a.absenceType,
startDate: a.startDate,
endDate: a.endDate,
startTime: a.startTime,
endTime: a.endTime,
daysCount: a.daysCount,
status: a.status,
reason: a.reason,
approvedBy: a.approvedBy,
createdAt: a.createdAt,
employee: employee,
});
});
}
return absences;
};
// Группировка событий по датам
const eventsByDate = useMemo(() => {
const eventsMap: Record<string, Array<{
id: string | number;
type: string;
startDate: string;
endDate?: string;
startTime?: string;
endTime?: string;
daysCount: number;
status: string;
reason?: string;
approvedBy?: string;
createdAt: string;
employee: Employee;
}>> = {};
// Инициализация всех дат
dateList.forEach(date => {
const dateKey = date.toISOString().split('T')[0];
eventsMap[dateKey] = [];
});
// Сбор всех событий
filteredEmployees.forEach(employee => {
const absences = getAllAbsences(employee);
absences.forEach(absence => {
const startDate = new Date(absence.startDate);
const endDate = absence.endDate ? new Date(absence.endDate) : startDate;
// Проверяем каждую дату в диапазоне
dateList.forEach(date => {
const dateKey = date.toISOString().split('T')[0];
const currentDateOnly = new Date(date);
currentDateOnly.setHours(0, 0, 0, 0);
const startDateOnly = new Date(startDate);
startDateOnly.setHours(0, 0, 0, 0);
const endDateOnly = new Date(endDate);
endDateOnly.setHours(0, 0, 0, 0);
// Если событие попадает на эту дату
if (currentDateOnly >= startDateOnly && currentDateOnly <= endDateOnly) {
// Фильтрация по типу и статусу
const matchesType = filterType === 'all' || absence.type === filterType;
const matchesStatus = filterStatus === 'all' || absence.status === filterStatus;
if (matchesType && matchesStatus) {
// Проверяем, нет ли уже этого события для этой даты
if (!eventsMap[dateKey].some(e => e.id === absence.id && e.employee.id === employee.id)) {
eventsMap[dateKey].push(absence);
}
}
}
});
});
});
return eventsMap;
}, [filteredEmployees, dateList, filterType, filterStatus]);
const effectiveActAsEmployee =
filteredEmployees.length > 0
? filteredEmployees[0]
: (currentUser?.id ? activeEmployees.find((e) => e.id === currentUser.id) : null) ?? activeEmployees[0];
if (loading && employees.length === 0) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Загрузка...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Заголовок и кнопка добавления */}
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
<div>
<h3 className="text-lg font-bold text-slate-800">Рабочий календарь</h3>
<p className="text-xs text-slate-500 mt-1">Управление отпусками, отгулами, больничными и пропусками</p>
</div>
{activeEmployees.length > 0 && (
<button
onClick={() => {
const emp = effectiveActAsEmployee ?? activeEmployees[0];
if (emp) handleOpenModal(emp);
}}
className="bg-primary-600 text-white px-4 py-2 rounded-xl text-xs font-bold hover:bg-primary-700 transition-colors flex items-center gap-2 shadow-lg shadow-primary-500/20"
>
<Plus className="w-4 h-4"/> Добавить запись
</button>
)}
</div>
{sickLeaveActionError && !sickLeaveCloseModal && !sickLeaveExtendModal && !vacationRejectModal && !absenceRejectModal && (
<div className="flex items-center justify-between gap-4 p-4 bg-red-50 border border-red-200 rounded-xl">
<p className="text-sm text-red-700">{sickLeaveActionError}</p>
<button type="button" onClick={() => setSickLeaveActionError('')} className="p-1.5 text-red-600 hover:bg-red-100 rounded-lg">
<X className="w-4 h-4"/>
</button>
</div>
)}
{/* Ожидают вашего согласования: отпуска и отгулы */}
{(pendingVacations.length > 0 || pendingDayOffs.length > 0) && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<h4 className="text-sm font-bold text-amber-800 flex items-center gap-2 mb-3">
<AlertCircle className="w-4 h-4"/> Ожидают вашего согласования
</h4>
<div className="space-y-2">
{pendingVacations.map((v) => (
<div key={`v-${v.employee.id}-${v.id}`} className="flex flex-wrap items-center justify-between gap-2 py-2 border-b border-amber-100 last:border-0">
<div>
<span className="font-medium text-slate-800">{v.employee.name}</span>
<span className="text-amber-700 text-xs ml-2">Отпуск</span>
<span className="text-slate-600 text-xs ml-2">{formatDate(v.startDate)} {formatDate(v.endDate)} ({v.daysCount} д.)</span>
</div>
<div className="flex gap-2">
<button type="button" disabled={vacationApprovingKey === `${v.employee.id}-${v.id}`} onClick={() => handleApproveVacation(v.employee.id, v.id)} className="px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-bold hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5"/> Утвердить</button>
<button type="button" onClick={() => { setVacationRejectModal({ employeeId: v.employee.id, vacationId: v.id, employee: v.employee }); setVacationRejectReason(''); setSickLeaveActionError(''); }} className="px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-bold hover:bg-red-700 flex items-center gap-1"><XCircle className="w-3.5 h-3.5"/> Отклонить</button>
</div>
</div>
))}
{pendingDayOffs.map((a) => (
<div key={`a-${a.employee.id}-${a.id}`} className="flex flex-wrap items-center justify-between gap-2 py-2 border-b border-amber-100 last:border-0">
<div>
<span className="font-medium text-slate-800">{a.employee.name}</span>
<span className="text-amber-700 text-xs ml-2">Отгул</span>
<span className="text-slate-600 text-xs ml-2">{formatDate(a.startDate)}{a.endDate ? ` ${formatDate(a.endDate)}` : ''}{a.reason ? `${a.reason}` : ''}</span>
</div>
<div className="flex gap-2">
<button type="button" disabled={absenceApprovingKey === `abs-${a.employee.id}-${a.id}`} onClick={() => handleApproveAbsence(a.employee.id, a.id)} className="px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-bold hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5"/> Утвердить</button>
<button type="button" onClick={() => { setAbsenceRejectModal({ employeeId: a.employee.id, absenceId: a.id, employee: a.employee }); setAbsenceRejectReason(''); setSickLeaveActionError(''); }} className="px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-bold hover:bg-red-700 flex items-center gap-1"><XCircle className="w-3.5 h-3.5"/> Отклонить</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Оформленные больничные (информация, согласование не требуется) */}
{recentSickLeaves.length > 0 && (
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<h4 className="text-sm font-bold text-slate-700 flex items-center gap-2 mb-3">
<Clock className="w-4 h-4"/> Оформленные больничные
</h4>
<p className="text-xs text-slate-500 mb-2">Сотрудники на больничном. Согласование руководителем не требуется.</p>
<div className="space-y-1.5">
{recentSickLeaves.map((s) => (
<div key={`s-${s.employee.id}-${s.id}`} className="flex items-center justify-between gap-2 py-1.5 text-sm">
<span className="font-medium text-slate-800">{s.employee.name}</span>
<span className="text-slate-600 text-xs">{formatDate(s.startDate)}{s.endDate ? ` ${formatDate(s.endDate)}` : ''}{s.expectedReturnDate ? ` • предв. выход: ${formatDate(s.expectedReturnDate)}` : ''}</span>
</div>
))}
</div>
</div>
)}
{/* Карточка: имя сотрудника (начните печатать) и фильтры */}
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">Имя сотрудника</label>
<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={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Начните печатать имя..."
className="w-full pl-10 pr-4 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
{searchQuery.trim() && (
<p className="text-xs text-slate-500 mt-1">
Найдено: {filteredEmployees.length} {filteredEmployees.length === 1 ? 'сотрудник' : filteredEmployees.length < 5 ? 'сотрудника' : 'сотрудников'}
{filteredEmployees.length > 0 && ' • «Добавить запись» откроет форму для первого в списке'}
</p>
)}
</div>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-4 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="all">Все типы</option>
<option value="vacation">Отпуск</option>
<option value="sick_leave">Больничный</option>
<option value="day_off">Отгул</option>
<option value="absence">Прогул</option>
<option value="late">Опоздание</option>
<option value="early_leave">Ранний уход</option>
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-4 py-2 rounded-lg border border-slate-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="all">Все статусы</option>
<option value="pending">На согласовании</option>
<option value="approved">Утверждено</option>
<option value="rejected">Отклонено</option>
<option value="active">Активно</option>
<option value="completed">Завершено</option>
</select>
</div>
</div>
{/* Навигация по датам */}
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between gap-4">
<button
onClick={handlePreviousDays}
className="p-2 rounded-lg border border-slate-200 hover:bg-slate-50 transition-colors flex items-center gap-2"
title="Предыдущие дни"
>
<ChevronLeft className="w-5 h-5 text-slate-600"/>
</button>
<div className="flex-1 flex items-center justify-center gap-4">
<div className="text-center">
<div className="text-sm font-bold text-slate-800">
{formatDateShort(dateList[0])} - {formatDateShort(dateList[dateList.length - 1])}
</div>
<div className="text-xs text-slate-500 mt-1">
{daysToShow} {daysToShow === 1 ? 'день' : daysToShow < 5 ? 'дня' : 'дней'}
</div>
</div>
<button
onClick={handleToday}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold hover:bg-slate-200 transition-colors"
>
Сегодня
</button>
</div>
<button
onClick={handleNextDays}
className="p-2 rounded-lg border border-slate-200 hover:bg-slate-50 transition-colors flex items-center gap-2"
title="Следующие дни"
>
<ChevronRight className="w-5 h-5 text-slate-600"/>
</button>
</div>
</div>
{/* Календарь по датам */}
<div className="space-y-4">
{dateList.map((date, index) => {
const dateKey = date.toISOString().split('T')[0];
const events = eventsByDate[dateKey] || [];
const isToday = date.toDateString() === new Date().toDateString();
return (
<div
key={dateKey}
className={`bg-white rounded-xl border overflow-hidden ${
isToday ? 'border-primary-500 shadow-lg shadow-primary-500/10' : 'border-slate-200'
}`}
>
{/* Заголовок даты */}
<div className={`p-4 border-b ${isToday ? 'bg-primary-50 border-primary-200' : 'bg-slate-50 border-slate-200'}`}>
<div className="flex items-center justify-between">
<div>
<h4 className={`font-bold ${isToday ? 'text-primary-800' : 'text-slate-800'}`}>
{formatDateWeekday(date)}
</h4>
<p className={`text-xs mt-1 ${isToday ? 'text-primary-600' : 'text-slate-500'}`}>
{formatDateShort(date)}
{isToday && <span className="ml-2 font-bold"> Сегодня</span>}
</p>
</div>
<div className="text-sm font-bold text-slate-400">
{events.length} {events.length === 1 ? 'событие' : events.length < 5 ? 'события' : 'событий'}
</div>
</div>
</div>
{/* События на эту дату */}
{events.length > 0 ? (
<div className="p-4 space-y-3">
{events.map((absence, eventIndex) => {
const typeInfo = getAbsenceTypeLabel(absence.type);
const statusInfo = getStatusLabel(absence.status);
return (
<div
key={`${absence.type}-${absence.id}-${eventIndex}`}
className="bg-slate-50 rounded-lg p-4 border border-slate-200 hover:border-primary-300 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-lg">{typeInfo.icon}</span>
<span className={`text-xs font-bold px-2 py-1 rounded-full border ${typeInfo.color}`}>
{typeInfo.label}
</span>
<span className={`text-[9px] font-black px-2 py-1 rounded-full uppercase tracking-tighter ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
<div className="text-sm font-bold text-slate-800 mb-1">
{absence.employee.name} {absence.employee.position}
</div>
<div className="text-xs text-slate-600 mb-1">
{absence.startTime && (
<>Время: {absence.startTime}</>
)}
{absence.endTime && (
<> - {absence.endTime}</>
)}
{!absence.startTime && absence.endDate && absence.endDate !== absence.startDate && (
<>Период: {formatDate(absence.startDate)} - {formatDate(absence.endDate)}</>
)}
{!absence.startTime && (!absence.endDate || absence.endDate === absence.startDate) && (
<>Дата: {formatDate(absence.startDate)}</>
)}
</div>
<div className="text-xs text-slate-500">
{absence.daysCount > 0 && (
<>{absence.daysCount} {absence.daysCount === 1 ? 'день' : absence.daysCount < 5 ? 'дня' : 'дней'}</>
)}
{absence.type === 'sick_leave' && (absence as { expectedReturnDate?: string }).expectedReturnDate && (
<> Предв. выход: {formatDate((absence as { expectedReturnDate?: string }).expectedReturnDate!)}</>
)}
{absence.reason && `${absence.reason}`}
{absence.approvedBy && ` • Утверждено: ${absence.approvedBy}`}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2 flex-shrink-0">
{absence.type === 'vacation' && absence.status === 'planned' && canApproveVacation(absence) && (
<>
<button
type="button"
disabled={vacationApprovingKey === `${absence.employee.id}-${absence.id}`}
onClick={() => handleApproveVacation(absence.employee.id, Number(absence.id))}
className="px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-bold hover:bg-emerald-700 transition-colors disabled:opacity-50 flex items-center gap-1"
>
<CheckCircle className="w-3.5 h-3.5"/> Утвердить
</button>
<button
type="button"
onClick={() => {
setVacationRejectModal({
employeeId: absence.employee.id,
vacationId: Number(absence.id),
employee: absence.employee,
});
setVacationRejectReason('');
setSickLeaveActionError('');
}}
className="px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-bold hover:bg-red-700 transition-colors flex items-center gap-1"
>
<XCircle className="w-3.5 h-3.5"/> Отклонить
</button>
</>
)}
{absence.type === 'day_off' && absence.status === 'pending' && canApproveDayOff(absence) && (
<>
<button
type="button"
disabled={absenceApprovingKey === `abs-${absence.employee.id}-${absence.id}`}
onClick={() => handleApproveAbsence(absence.employee.id, Number(absence.id))}
className="px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-bold hover:bg-emerald-700 transition-colors disabled:opacity-50 flex items-center gap-1"
>
<CheckCircle className="w-3.5 h-3.5"/> Утвердить
</button>
<button
type="button"
onClick={() => {
setAbsenceRejectModal({
employeeId: absence.employee.id,
absenceId: Number(absence.id),
employee: absence.employee,
});
setAbsenceRejectReason('');
setSickLeaveActionError('');
}}
className="px-3 py-1.5 bg-red-600 text-white rounded-lg text-xs font-bold hover:bg-red-700 transition-colors flex items-center gap-1"
>
<XCircle className="w-3.5 h-3.5"/> Отклонить
</button>
</>
)}
{absence.type === 'sick_leave' && absence.status === 'active' && (
<>
<button
type="button"
onClick={() => {
setSickLeaveCloseModal({
employeeId: absence.employee.id,
sickLeaveId: Number(absence.id),
employee: absence.employee,
});
setCloseEndDate(new Date().toISOString().split('T')[0]);
setCloseSickLeaveNumber('');
setSickLeaveActionError('');
}}
className="px-3 py-1.5 bg-emerald-600 text-white rounded-lg text-xs font-bold hover:bg-emerald-700 transition-colors"
>
Выход с больничного
</button>
<button
type="button"
onClick={() => {
setSickLeaveExtendModal({
employeeId: absence.employee.id,
sickLeaveId: Number(absence.id),
employee: absence.employee,
});
setExtendExpectedReturnDate((absence as { expectedReturnDate?: string }).expectedReturnDate || '');
setSickLeaveActionError('');
}}
className="px-3 py-1.5 bg-amber-600 text-white rounded-lg text-xs font-bold hover:bg-amber-700 transition-colors"
>
Перенести дату выхода
</button>
</>
)}
<button
onClick={() => handleOpenModal(absence.employee)}
className="px-3 py-1.5 bg-primary-600 text-white rounded-lg text-xs font-bold hover:bg-primary-700 transition-colors flex items-center gap-2"
title="Добавить запись"
>
<Plus className="w-3 h-3"/> Добавить
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="p-4 text-center text-sm text-slate-400">
Нет событий на эту дату
</div>
)}
</div>
);
})}
</div>
{/* Модалка: Отклонить отпуск */}
{vacationRejectModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setVacationRejectModal(null)}>
<div className="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h4 className="text-lg font-bold text-slate-800 mb-2">Отклонить отпуск</h4>
<p className="text-sm text-slate-600 mb-4">{vacationRejectModal.employee.name}</p>
{sickLeaveActionError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{sickLeaveActionError}</div>
)}
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Причина отклонения (необязательно)</label>
<textarea
value={vacationRejectReason}
onChange={e => setVacationRejectReason(e.target.value)}
rows={3}
placeholder="Укажите причину"
className="w-full px-4 py-2 rounded-lg border border-slate-200 resize-none"
/>
</div>
<div className="flex gap-2 mt-6">
<button type="button" onClick={() => setVacationRejectModal(null)} className="flex-1 py-2 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold">Отмена</button>
<button type="button" onClick={handleRejectVacation} disabled={sickLeaveActionLoading} className="flex-1 py-2 bg-red-600 text-white rounded-xl text-sm font-bold disabled:opacity-50">Отклонить</button>
</div>
</div>
</div>
)}
{/* Модалка: Отклонить отгул */}
{absenceRejectModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setAbsenceRejectModal(null)}>
<div className="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h4 className="text-lg font-bold text-slate-800 mb-2">Отклонить отгул</h4>
<p className="text-sm text-slate-600 mb-4">{absenceRejectModal.employee.name}</p>
{sickLeaveActionError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{sickLeaveActionError}</div>
)}
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Причина отклонения (необязательно)</label>
<textarea
value={absenceRejectReason}
onChange={e => setAbsenceRejectReason(e.target.value)}
rows={3}
placeholder="Укажите причину"
className="w-full px-4 py-2 rounded-lg border border-slate-200 resize-none"
/>
</div>
<div className="flex gap-2 mt-6">
<button type="button" onClick={() => setAbsenceRejectModal(null)} className="flex-1 py-2 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold">Отмена</button>
<button type="button" onClick={handleRejectAbsence} disabled={sickLeaveActionLoading} className="flex-1 py-2 bg-red-600 text-white rounded-xl text-sm font-bold disabled:opacity-50">Отклонить</button>
</div>
</div>
</div>
)}
{/* Модалка: Выход с больничного */}
{sickLeaveCloseModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setSickLeaveCloseModal(null)}>
<div className="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h4 className="text-lg font-bold text-slate-800 mb-2">Выход с больничного</h4>
<p className="text-sm text-slate-600 mb-4">{sickLeaveCloseModal.employee.name}</p>
{sickLeaveActionError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{sickLeaveActionError}</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Дата выхода</label>
<input
type="date"
value={closeEndDate}
onChange={e => setCloseEndDate(e.target.value)}
className="w-full px-4 py-2 rounded-lg border border-slate-200"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Номер больничного листа <span className="text-red-600">*</span></label>
<input
type="text"
value={closeSickLeaveNumber}
onChange={e => setCloseSickLeaveNumber(e.target.value)}
placeholder="Обязательно при выходе"
className="w-full px-4 py-2 rounded-lg border border-slate-200"
/>
</div>
</div>
<div className="flex gap-2 mt-6">
<button type="button" onClick={() => setSickLeaveCloseModal(null)} className="flex-1 py-2 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold">Отмена</button>
<button type="button" onClick={handleCloseSickLeave} disabled={sickLeaveActionLoading} className="flex-1 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold disabled:opacity-50">Выход с больничного</button>
</div>
</div>
</div>
)}
{/* Модалка: Перенести дату выхода */}
{sickLeaveExtendModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={() => setSickLeaveExtendModal(null)}>
<div className="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h4 className="text-lg font-bold text-slate-800 mb-2">Перенести дату выхода</h4>
<p className="text-sm text-slate-600 mb-2">{sickLeaveExtendModal.employee.name}</p>
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg p-2 mb-4">Уведомить сотрудника: сегодня закрыть или перенести дату выхода.</p>
{sickLeaveActionError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{sickLeaveActionError}</div>
)}
<div>
<label className="block text-sm font-bold text-slate-700 mb-1">Новая предварительная дата выхода</label>
<input
type="date"
value={extendExpectedReturnDate}
onChange={e => setExtendExpectedReturnDate(e.target.value)}
className="w-full px-4 py-2 rounded-lg border border-slate-200"
/>
</div>
<div className="flex gap-2 mt-6">
<button type="button" onClick={() => setSickLeaveExtendModal(null)} className="flex-1 py-2 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold">Отмена</button>
<button type="button" onClick={handleExtendSickLeave} disabled={sickLeaveActionLoading || !extendExpectedReturnDate} className="flex-1 py-2 bg-amber-600 text-white rounded-xl text-sm font-bold disabled:opacity-50">Перенести</button>
</div>
</div>
</div>
)}
{/* Модальное окно добавления записи */}
{isModalOpen && selectedEmployee && (
<WorkCalendarModal
employee={selectedEmployee}
onClose={handleCloseModal}
onSave={handleSave}
type={modalType}
currentUser={currentUser}
employees={activeEmployees}
selectableEmployees={actAsOptions}
onEmployeeChange={setSelectedEmployee}
/>
)}
</div>
);
};