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 = ({ currentUser }) => { const [employees, setEmployees] = useState([]); const [selectedEmployee, setSelectedEmployee] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [modalType, setModalType] = useState(undefined); const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [filterType, setFilterType] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const [currentDate, setCurrentDate] = useState(new Date()); const [daysToShow, setDaysToShow] = useState(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(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(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 = { 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 = { 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> = {}; // Инициализация всех дат 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 (
Загрузка...
); } return (
{/* Заголовок и кнопка добавления */}

Рабочий календарь

Управление отпусками, отгулами, больничными и пропусками

{activeEmployees.length > 0 && ( )}
{sickLeaveActionError && !sickLeaveCloseModal && !sickLeaveExtendModal && !vacationRejectModal && !absenceRejectModal && (

{sickLeaveActionError}

)} {/* Ожидают вашего согласования: отпуска и отгулы */} {(pendingVacations.length > 0 || pendingDayOffs.length > 0) && (

Ожидают вашего согласования

{pendingVacations.map((v) => (
{v.employee.name} Отпуск {formatDate(v.startDate)} – {formatDate(v.endDate)} ({v.daysCount} д.)
))} {pendingDayOffs.map((a) => (
{a.employee.name} Отгул {formatDate(a.startDate)}{a.endDate ? ` – ${formatDate(a.endDate)}` : ''}{a.reason ? ` • ${a.reason}` : ''}
))}
)} {/* Оформленные больничные (информация, согласование не требуется) */} {recentSickLeaves.length > 0 && (

Оформленные больничные

Сотрудники на больничном. Согласование руководителем не требуется.

{recentSickLeaves.map((s) => (
{s.employee.name} {formatDate(s.startDate)}{s.endDate ? ` – ${formatDate(s.endDate)}` : ''}{s.expectedReturnDate ? ` • предв. выход: ${formatDate(s.expectedReturnDate)}` : ''}
))}
)} {/* Карточка: имя сотрудника (начните печатать) и фильтры */}
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" />
{searchQuery.trim() && (

Найдено: {filteredEmployees.length} {filteredEmployees.length === 1 ? 'сотрудник' : filteredEmployees.length < 5 ? 'сотрудника' : 'сотрудников'} {filteredEmployees.length > 0 && ' • «Добавить запись» откроет форму для первого в списке'}

)}
{/* Навигация по датам */}
{formatDateShort(dateList[0])} - {formatDateShort(dateList[dateList.length - 1])}
{daysToShow} {daysToShow === 1 ? 'день' : daysToShow < 5 ? 'дня' : 'дней'}
{/* Календарь по датам */}
{dateList.map((date, index) => { const dateKey = date.toISOString().split('T')[0]; const events = eventsByDate[dateKey] || []; const isToday = date.toDateString() === new Date().toDateString(); return (
{/* Заголовок даты */}

{formatDateWeekday(date)}

{formatDateShort(date)} {isToday && • Сегодня}

{events.length} {events.length === 1 ? 'событие' : events.length < 5 ? 'события' : 'событий'}
{/* События на эту дату */} {events.length > 0 ? (
{events.map((absence, eventIndex) => { const typeInfo = getAbsenceTypeLabel(absence.type); const statusInfo = getStatusLabel(absence.status); return (
{typeInfo.icon} {typeInfo.label} {statusInfo.label}
{absence.employee.name} • {absence.employee.position}
{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)} )}
{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}`}
{absence.type === 'vacation' && absence.status === 'planned' && canApproveVacation(absence) && ( <> )} {absence.type === 'day_off' && absence.status === 'pending' && canApproveDayOff(absence) && ( <> )} {absence.type === 'sick_leave' && absence.status === 'active' && ( <> )}
); })}
) : (
Нет событий на эту дату
)}
); })}
{/* Модалка: Отклонить отпуск */} {vacationRejectModal && (
setVacationRejectModal(null)}>
e.stopPropagation()}>

Отклонить отпуск

{vacationRejectModal.employee.name}

{sickLeaveActionError && (
{sickLeaveActionError}
)}