1075 lines
53 KiB
TypeScript
Executable File
1075 lines
53 KiB
TypeScript
Executable File
|
||
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>
|
||
);
|
||
};
|