Files
mkd/components/hr/WorkCalendarView.tsx

1075 lines
53 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};