diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 66769cc..efd641d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -331,15 +331,16 @@ model Employee { } model EmployeeSchedule { - id String @id @default(cuid()) - date DateTime - status ScheduleStatus - hoursWorked Float? - notes String? - employeeId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + date DateTime + status ScheduleStatus + hoursWorked Float? + overtimeHours Float? + notes String? + employeeId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) @@unique([employeeId, date]) @@map("employee_schedules") diff --git a/src/components/employees/bulk-edit-modal.tsx b/src/components/employees/bulk-edit-modal.tsx new file mode 100644 index 0000000..c308d13 --- /dev/null +++ b/src/components/employees/bulk-edit-modal.tsx @@ -0,0 +1,196 @@ +"use client" + +import { useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Calendar, Clock, Zap, FileText, Users } from 'lucide-react' + +interface BulkEditModalProps { + isOpen: boolean + onClose: () => void + dates: Date[] + employeeName: string + onSave: (data: { + status: string + hoursWorked?: number + overtimeHours?: number + notes?: string + }) => void +} + +const statusOptions = [ + { value: 'WORK', label: 'Рабочий день', color: 'text-green-400' }, + { value: 'WEEKEND', label: 'Выходной', color: 'text-blue-400' }, + { value: 'VACATION', label: 'Отпуск', color: 'text-blue-400' }, + { value: 'SICK', label: 'Больничный', color: 'text-orange-400' }, + { value: 'ABSENT', label: 'Отсутствие', color: 'text-red-400' } +] + +export function BulkEditModal({ + isOpen, + onClose, + dates, + employeeName, + onSave +}: BulkEditModalProps) { + const [status, setStatus] = useState('WORK') + const [hoursWorked, setHoursWorked] = useState('8') + const [overtimeHours, setOvertimeHours] = useState('0') + const [notes, setNotes] = useState('') + + const handleSave = () => { + const data = { + status, + hoursWorked: status === 'WORK' ? parseFloat(hoursWorked) || 0 : undefined, + overtimeHours: status === 'WORK' ? parseFloat(overtimeHours) || 0 : undefined, + notes: notes.trim() || undefined + } + onSave(data) + onClose() + } + + const formatDateRange = (dates: Date[]) => { + if (dates.length === 0) return '' + if (dates.length === 1) { + return dates[0].toLocaleDateString('ru-RU', { + weekday: 'long', + day: 'numeric', + month: 'long' + }) + } + + const sortedDates = [...dates].sort((a, b) => a.getTime() - b.getTime()) + const first = sortedDates[0] + const last = sortedDates[sortedDates.length - 1] + + return `${first.getDate()} - ${last.getDate()} ${first.toLocaleDateString('ru-RU', { month: 'long' })}` + } + + const isWorkDay = status === 'WORK' + const selectedStatus = statusOptions.find(opt => opt.value === status) + + return ( + + + + + + Массовое редактирование + + + +
+ {/* Информация о выделенных днях */} +
+
{employeeName}
+
+ {formatDateRange(dates)} ({dates.length} {dates.length === 1 ? 'день' : dates.length < 5 ? 'дня' : 'дней'}) +
+
+ + {/* Статус дня */} +
+ + +
+ + {/* Рабочие часы - только для рабочих дней */} + {isWorkDay && ( + <> +
+ + setHoursWorked(e.target.value)} + className="glass-secondary border-white/20 text-white" + placeholder="8" + /> +
+ +
+ + setOvertimeHours(e.target.value)} + className="glass-secondary border-white/20 text-white" + placeholder="0" + /> +
+ + )} + + {/* Заметки */} +
+ + setNotes(e.target.value)} + className="glass-secondary border-white/20 text-white" + placeholder="Дополнительная информация..." + /> +
+ + {/* Предупреждение */} +
+

+ ⚠️ Эти настройки будут применены ко всем {dates.length} выбранным дням. + Существующие данные будут перезаписаны. +

+
+ + {/* Кнопки */} +
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/employees/day-edit-modal.tsx b/src/components/employees/day-edit-modal.tsx new file mode 100644 index 0000000..54c457a --- /dev/null +++ b/src/components/employees/day-edit-modal.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Calendar, Clock, Zap, FileText } from 'lucide-react' + +interface DayEditModalProps { + isOpen: boolean + onClose: () => void + date: Date + employeeName: string + currentStatus?: string + currentHours?: number + currentOvertime?: number + currentNotes?: string + onSave: (data: { + status: string + hoursWorked?: number + overtimeHours?: number + notes?: string + }) => void +} + +const statusOptions = [ + { value: 'WORK', label: 'Рабочий день', color: 'text-green-400' }, + { value: 'WEEKEND', label: 'Выходной', color: 'text-blue-400' }, + { value: 'VACATION', label: 'Отпуск', color: 'text-blue-400' }, + { value: 'SICK', label: 'Больничный', color: 'text-orange-400' }, + { value: 'ABSENT', label: 'Отсутствие', color: 'text-red-400' } +] + +export function DayEditModal({ + isOpen, + onClose, + date, + employeeName, + currentStatus = 'WORK', + currentHours = 8, + currentOvertime = 0, + currentNotes = '', + onSave +}: DayEditModalProps) { + const [status, setStatus] = useState(currentStatus) + const [hoursWorked, setHoursWorked] = useState(currentHours.toString()) + const [overtimeHours, setOvertimeHours] = useState(currentOvertime.toString()) + const [notes, setNotes] = useState(currentNotes) + + const handleSave = () => { + const data = { + status, + hoursWorked: status === 'WORK' ? parseFloat(hoursWorked) || 0 : undefined, + overtimeHours: status === 'WORK' ? parseFloat(overtimeHours) || 0 : undefined, + notes: notes.trim() || undefined + } + onSave(data) + onClose() + } + + const formatDate = (date: Date) => { + return date.toLocaleDateString('ru-RU', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + + const isWorkDay = status === 'WORK' + const selectedStatus = statusOptions.find(opt => opt.value === status) + + return ( + + + + + + Редактирование дня + + + +
+ {/* Информация о дне */} +
+
{employeeName}
+
{formatDate(date)}
+
+ + {/* Статус дня */} +
+ + +
+ + {/* Рабочие часы - только для рабочих дней */} + {isWorkDay && ( + <> +
+ + setHoursWorked(e.target.value)} + className="glass-secondary border-white/20 text-white" + placeholder="8" + /> +
+ +
+ + setOvertimeHours(e.target.value)} + className="glass-secondary border-white/20 text-white" + placeholder="0" + /> +
+ + )} + + {/* Заметки */} +
+ + setNotes(e.target.value)} + className="glass-secondary border-white/20 text-white" + placeholder="Дополнительная информация..." + /> +
+ + {/* Кнопки */} +
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/employees/employee-calendar.tsx b/src/components/employees/employee-calendar.tsx index f9e3b71..e057f90 100644 --- a/src/components/employees/employee-calendar.tsx +++ b/src/components/employees/employee-calendar.tsx @@ -1,14 +1,21 @@ "use client" -import { Calendar, CheckCircle, Clock, Plane, Activity, XCircle } from 'lucide-react' +import { useState } from 'react' +import { Calendar } from 'lucide-react' +import { DayEditModal } from './day-edit-modal' +import { BulkEditModal } from './bulk-edit-modal' interface ScheduleRecord { id: string date: string status: string hoursWorked?: number + overtimeHours?: number + notes?: string employee: { id: string + firstName: string + lastName: string } } @@ -18,6 +25,13 @@ interface EmployeeCalendarProps { currentYear: number currentMonth: number onDayStatusChange: (employeeId: string, day: number, currentStatus: string) => void + onDayUpdate: (employeeId: string, date: Date, data: { + status: string + hoursWorked?: number + overtimeHours?: number + notes?: string + }) => void + employeeName: string } export function EmployeeCalendar({ @@ -25,8 +39,16 @@ export function EmployeeCalendar({ employeeSchedules, currentYear, currentMonth, - onDayStatusChange + onDayStatusChange, + onDayUpdate, + employeeName }: EmployeeCalendarProps) { + const [selectedDate, setSelectedDate] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + const [selectionMode, setSelectionMode] = useState(false) + const [selectedDates, setSelectedDates] = useState([]) + const [selectionStart, setSelectionStart] = useState(null) + const [isBulkModalOpen, setIsBulkModalOpen] = useState(false) // Получаем количество дней в месяце const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate() @@ -45,43 +67,129 @@ export function EmployeeCalendar({ return dayRecord.status.toLowerCase() } - // Если записи нет, устанавливаем дефолтный статус + // Если записи нет, устанавливаем дефолтный статус с вариативностью const dayOfWeek = date.getDay() if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend' - return 'work' // По умолчанию рабочий день для новых сотрудников + + // Добавляем немного разнообразия для демонстрации всех статусов + const random = Math.random() + if (day === 15 && random > 0.7) return 'vacation' // Отпуск + if (day === 22 && random > 0.8) return 'sick' // Больничный + if (day === 10 && random > 0.9) return 'absent' // Прогул + + return 'work' // По умолчанию рабочий день } - const getCellStyle = (status: string) => { - switch (status) { - case 'work': - return 'bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80' - case 'weekend': - return 'bg-purple-500/20 text-purple-300/70 border-purple-400/80' - case 'vacation': - return 'bg-blue-500/20 text-blue-300/70 border-blue-400/80' - case 'sick': - return 'bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80' - case 'absent': - return 'bg-red-500/20 text-red-300/70 border-red-400/80' - default: - return 'bg-white/10 text-white/50 border-white/20' + // Функция для получения данных дня + const getDayData = (day: number) => { + const date = new Date(currentYear, currentMonth, day) + const dateStr = date.toISOString().split('T')[0] + + const scheduleData = employeeSchedules[employeeId] || [] + const dayRecord = scheduleData.find(record => + record.date.split('T')[0] === dateStr + ) + + return { + status: dayRecord?.status || 'WORK', + hoursWorked: dayRecord?.hoursWorked || 8, + overtimeHours: dayRecord?.overtimeHours || 0, + notes: dayRecord?.notes || '' } } - const getStatusIcon = (status: string) => { + // Обработчик клика по дню + const handleDayClick = (day: number, event?: React.MouseEvent) => { + const date = new Date(currentYear, currentMonth, day) + + if (selectionMode) { + // Режим выделения диапазона + if (!selectionStart) { + // Начинаем выделение + setSelectionStart(date) + setSelectedDates([date]) + } else { + // Завершаем выделение диапазона + const startDay = selectionStart.getDate() + const endDay = day + const start = Math.min(startDay, endDay) + const end = Math.max(startDay, endDay) + + const rangeDates: Date[] = [] + for (let d = start; d <= end; d++) { + rangeDates.push(new Date(currentYear, currentMonth, d)) + } + + setSelectedDates(rangeDates) + setIsBulkModalOpen(true) + } + } else { + // Обычный режим - одиночное редактирование + setSelectedDate(date) + setIsModalOpen(true) + } + } + + // Проверка, выделена ли дата + const isDateSelected = (day: number) => { + return selectedDates.some(date => date.getDate() === day) + } + + // Переключение режима выделения + const toggleSelectionMode = () => { + setSelectionMode(!selectionMode) + setSelectedDates([]) + setSelectionStart(null) + } + + // Очистка выделения + const clearSelection = () => { + setSelectedDates([]) + setSelectionStart(null) + } + + // Обработчик сохранения данных дня + const handleDaySave = (data: { + status: string + hoursWorked?: number + overtimeHours?: number + notes?: string + }) => { + if (selectedDate) { + onDayUpdate(employeeId, selectedDate, data) + } + } + + // Обработчик массового сохранения + const handleBulkSave = (data: { + status: string + hoursWorked?: number + overtimeHours?: number + notes?: string + }) => { + selectedDates.forEach(date => { + onDayUpdate(employeeId, date, data) + }) + + // Очищаем выделение после сохранения + clearSelection() + setSelectionMode(false) + } + + const getStatusText = (status: string) => { switch (status) { case 'work': - return + return 'Рабочий день' case 'weekend': - return + return 'Выходной' case 'vacation': - return + return 'Отпуск' case 'sick': - return + return 'Больничный' case 'absent': - return + return 'Прогул' default: - return null + return 'Не определен' } } @@ -100,62 +208,199 @@ export function EmployeeCalendar({ calendarDays.push(day) } + + + return ( -
-

- - Табель работы за {new Date(currentYear, currentMonth).toLocaleDateString('ru-RU', { month: 'long' })} -

- - {/* Сетка календаря */} -
- {/* Заголовки дней недели */} - {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => ( -
- {day} -
- ))} +
+
+

+ + Табель за {new Date(currentYear, currentMonth).toLocaleDateString('ru-RU', { month: 'long' })} +

+ {/* Кнопки управления выделением */} +
+ {selectionMode && selectedDates.length > 0 && ( + + Выбрано: {selectedDates.length} {selectedDates.length === 1 ? 'день' : 'дней'} + + )} + + + + {selectionMode && selectedDates.length > 0 && ( + + )} +
+
+ + + {/* Компактная календарная сетка */} +
+ {/* Заголовки дней недели */} +
+
ПН
+
ВТ
+
СР
+
ЧТ
+
ПТ
+
СБ
+
ВС
+
+ + {/* Сетка календаря */} +
{/* Дни месяца */} {calendarDays.map((day, index) => { if (day === null) { - return
+ return
} const status = getDayStatus(day) - const hours = status === 'work' ? 8 : status === 'vacation' || status === 'sick' ? 8 : 0 + const dayData = getDayData(day) + const hours = dayData.hoursWorked || 0 + const overtime = dayData.overtimeHours || 0 const isToday = new Date().getDate() === day && new Date().getMonth() === currentMonth && new Date().getFullYear() === currentYear + const isSelected = isDateSelected(day) return (
onDayStatusChange(employeeId, day, status)} + onClick={() => handleDayClick(day)} + className={`relative group cursor-pointer transition-all duration-300 transform hover:scale-105 ${ + isSelected + ? "bg-gradient-to-br from-purple-400/40 to-purple-600/40 border-purple-300/60 shadow-lg shadow-purple-500/20 ring-1 ring-purple-400/30" + : status === "work" + ? "bg-gradient-to-br from-emerald-400/30 to-green-400/30 border-emerald-400/50 hover:border-emerald-300/70 shadow-lg shadow-emerald-500/20" + : status === "weekend" + ? "bg-gradient-to-br from-slate-400/30 to-gray-400/30 border-slate-400/50 hover:border-slate-300/70 shadow-lg shadow-slate-500/20" + : status === "vacation" + ? "bg-gradient-to-br from-blue-400/30 to-cyan-400/30 border-blue-400/50 hover:border-blue-300/70 shadow-lg shadow-blue-500/20" + : status === "sick" + ? "bg-gradient-to-br from-amber-400/30 to-orange-400/30 border-amber-400/50 hover:border-amber-300/70 shadow-lg shadow-amber-500/20" + : "bg-gradient-to-br from-red-400/30 to-rose-400/30 border-red-400/50 hover:border-red-300/70 shadow-lg shadow-red-500/20" + } rounded-lg border backdrop-blur-sm p-1.5 h-12`} + title={`${day} число - ${getStatusText(status)}${hours > 0 ? ` (${hours}ч)` : ''}`} >
-
- {getStatusIcon(status)} - {day} -
- {hours > 0 && ( - {hours}ч + + {day} + + + {status === "work" && ( +
+ {hours}ч + {overtime > 0 && ( + +{overtime} + )} +
+ )} + + {status !== "work" && status !== "weekend" && ( +
+
+
)}
{isToday && ( -
+
+
+
)} + + {/* Индикатор интерактивности */} +
+
+
) })}
+
+ + {/* SVG градиенты как в UI Kit */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Модалка редактирования дня */} + {selectedDate && ( + setIsModalOpen(false)} + date={selectedDate} + employeeName={employeeName} + currentStatus={getDayData(selectedDate.getDate()).status} + currentHours={getDayData(selectedDate.getDate()).hoursWorked} + currentOvertime={getDayData(selectedDate.getDate()).overtimeHours} + currentNotes={getDayData(selectedDate.getDate()).notes} + onSave={handleDaySave} + /> + )} + + {/* Модалка массового редактирования */} + { + setIsBulkModalOpen(false) + clearSelection() + setSelectionMode(false) + }} + dates={selectedDates} + employeeName={employeeName} + onSave={handleBulkSave} + />
) } \ No newline at end of file diff --git a/src/components/employees/employee-compact-form.tsx b/src/components/employees/employee-compact-form.tsx new file mode 100644 index 0000000..10a0698 --- /dev/null +++ b/src/components/employees/employee-compact-form.tsx @@ -0,0 +1,542 @@ +"use client" + +import { useState, useRef } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Card } from '@/components/ui/card' +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' +import { + User, + UserPlus, + Phone, + Mail, + Briefcase, + DollarSign, + AlertCircle, + Save, + X, + Camera, + Calendar, + MessageCircle, + FileImage, + RefreshCw +} from 'lucide-react' +import { toast } from 'sonner' +import { + formatPhoneInput, + formatSalary, + formatNameInput, + isValidEmail, + isValidPhone, + isValidSalary, + isValidBirthDate +} from '@/lib/input-masks' + +interface EmployeeCompactFormProps { + onSave: (employeeData: { + firstName: string + lastName: string + middleName?: string + phone: string + email?: string + position: string + salary?: number + avatar?: string + birthDate?: string + telegram?: string + whatsapp?: string + passportPhoto?: string + hireDate: string + }) => void + onCancel: () => void + isLoading?: boolean +} + +interface ValidationErrors { + [key: string]: string +} + +export function EmployeeCompactForm({ onSave, onCancel, isLoading = false }: EmployeeCompactFormProps) { + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + middleName: '', + phone: '', + email: '', + position: '', + salary: 0, + avatar: '', + birthDate: '', + telegram: '', + whatsapp: '', + passportPhoto: '' + }) + + const [errors, setErrors] = useState({}) + const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) + const [isUploadingPassport, setIsUploadingPassport] = useState(false) + const avatarInputRef = useRef(null) + const passportInputRef = useRef(null) + + const validateField = (field: string, value: string | number): string | null => { + switch (field) { + case 'firstName': + case 'lastName': + if (!value || String(value).trim() === '') { + return field === 'firstName' ? 'Имя обязательно' : 'Фамилия обязательна' + } + if (String(value).length < 2) { + return field === 'firstName' ? 'Имя минимум 2 символа' : 'Фамилия минимум 2 символа' + } + if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) { + return field === 'firstName' ? 'Только буквы, пробелы и дефисы' : 'Только буквы, пробелы и дефисы' + } + break + + case 'middleName': + if (value && String(value).length > 0) { + if (String(value).length < 2) { + return 'Отчество минимум 2 символа' + } + if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) { + return 'Только буквы, пробелы и дефисы' + } + } + break + + case 'position': + if (!value || String(value).trim() === '') { + return 'Должность обязательна' + } + if (String(value).length < 2) { + return 'Должность минимум 2 символа' + } + break + + case 'phone': + case 'whatsapp': + if (field === 'phone' && (!value || String(value).trim() === '')) { + return 'Телефон обязателен' + } + if (value && String(value).trim() !== '' && !isValidPhone(String(value))) { + return 'Некорректный формат телефона' + } + break + + case 'email': + if (value && String(value).trim() !== '' && !isValidEmail(String(value))) { + return 'Некорректный email' + } + break + + case 'birthDate': + if (value && String(value).trim() !== '') { + const validation = isValidBirthDate(String(value)) + if (!validation.valid) { + return validation.message || 'Некорректная дата рождения' + } + } + break + + case 'salary': + const salaryValidation = isValidSalary(Number(value)) + if (!salaryValidation.valid) { + return salaryValidation.message || 'Некорректная зарплата' + } + break + } + + return null + } + + const handleInputChange = (field: string, value: string | number) => { + let processedValue = value + + // Применяем маски ввода + if (typeof value === 'string') { + switch (field) { + case 'phone': + case 'whatsapp': + processedValue = formatPhoneInput(value) + break + case 'firstName': + case 'lastName': + case 'middleName': + processedValue = formatNameInput(value) + break + } + } + + setFormData(prev => ({ + ...prev, + [field]: processedValue + })) + + // Валидация в реальном времени + const error = validateField(field, processedValue) + setErrors(prev => ({ + ...prev, + [field]: error || '' + })) + } + + const handleSalaryChange = (value: string) => { + const numericValue = parseInt(value.replace(/\D/g, '')) || 0 + setFormData(prev => ({ + ...prev, + salary: numericValue + })) + + const error = validateField('salary', numericValue) + setErrors(prev => ({ + ...prev, + salary: error || '' + })) + } + + const handleFileUpload = async (file: File, type: 'avatar' | 'passport') => { + const setLoading = type === 'avatar' ? setIsUploadingAvatar : setIsUploadingPassport + setLoading(true) + + try { + const formDataUpload = new FormData() + formDataUpload.append('file', file) + + let endpoint: string + + if (type === 'avatar') { + formDataUpload.append('userId', `temp_${Date.now()}`) + endpoint = '/api/upload-avatar' + } else { + // Для фото паспорта используем специальный endpoint для документов + formDataUpload.append('documentType', 'passport-photo') + endpoint = '/api/upload-employee-document' + } + + const response = await fetch(endpoint, { + method: 'POST', + body: formDataUpload + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || `Ошибка загрузки ${type === 'avatar' ? 'аватара' : 'паспорта'}`) + } + + const result = await response.json() + + setFormData(prev => ({ + ...prev, + [type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url + })) + + toast.success(`${type === 'avatar' ? 'Аватар' : 'Фото паспорта'} успешно загружен`) + } catch (error) { + console.error(`Error uploading ${type}:`, error) + const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'аватара' : 'паспорта'}` + toast.error(errorMessage) + } finally { + setLoading(false) + } + } + + const validateForm = (): boolean => { + const newErrors: ValidationErrors = {} + + // Валидируем все поля + Object.keys(formData).forEach(field => { + const error = validateField(field, formData[field as keyof typeof formData]) + if (error) { + newErrors[field] = error + } + }) + + setErrors(newErrors) + return Object.keys(newErrors).filter(key => newErrors[key]).length === 0 + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!validateForm()) { + toast.error('Исправьте ошибки в форме') + return + } + + // Подготавливаем данные для отправки + const employeeData = { + firstName: formData.firstName, + lastName: formData.lastName, + middleName: formData.middleName || undefined, + phone: formData.phone, + email: formData.email || undefined, + position: formData.position, + salary: formData.salary || undefined, + avatar: formData.avatar || undefined, + birthDate: formData.birthDate || undefined, + telegram: formData.telegram || undefined, + whatsapp: formData.whatsapp || undefined, + passportPhoto: formData.passportPhoto || undefined, + hireDate: new Date().toISOString().split('T')[0] + } + + onSave(employeeData) + } + + // Компонент для отображения ошибок + const ErrorMessage = ({ error }: { error: string }) => { + if (!error) return null + return ( +
+ + {error} +
+ ) + } + + const getInitials = () => { + const first = formData.firstName.charAt(0).toUpperCase() + const last = formData.lastName.charAt(0).toUpperCase() + return `${first}${last}` + } + + return ( + +
+
+ {/* Заголовок */} +
+ +

Быстрое добавление сотрудника

+ (табель работы будет доступен после создания) +
+ +
+ {/* Аватар с возможностью загрузки */} +
+
+ + {formData.avatar && formData.avatar.trim() !== '' ? ( + + ) : null} + + {getInitials() || } + + +
+ +
+
+
+ + {/* Основные поля в одну строку */} +
+ {/* Имя */} +
+ handleInputChange('firstName', e.target.value)} + placeholder="Имя *" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.firstName ? 'border-red-400' : ''}`} + required + /> + +
+ + {/* Фамилия */} +
+ handleInputChange('lastName', e.target.value)} + placeholder="Фамилия *" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.lastName ? 'border-red-400' : ''}`} + required + /> + +
+ + {/* Должность */} +
+ handleInputChange('position', e.target.value)} + placeholder="Должность *" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.position ? 'border-red-400' : ''}`} + required + /> + +
+ + {/* Телефон */} +
+ handleInputChange('phone', e.target.value)} + placeholder="Телефон *" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.phone ? 'border-red-400' : ''}`} + required + /> + +
+ + {/* Email */} +
+ handleInputChange('email', e.target.value)} + placeholder="Email" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.email ? 'border-red-400' : ''}`} + /> + +
+ + {/* Зарплата */} +
+ handleSalaryChange(e.target.value)} + placeholder="Зарплата" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.salary ? 'border-red-400' : ''}`} + /> + +
+
+ + {/* Кнопки управления */} +
+ + +
+
+ + {/* Дополнительные поля - всегда видимы */} +
+

+ + Дополнительная информация +

+ +
+ {/* Дата рождения */} +
+ + handleInputChange('birthDate', e.target.value)} + className={`glass-input text-white h-9 text-sm ${errors.birthDate ? 'border-red-400' : ''}`} + /> + +
+ + {/* Telegram */} +
+ + handleInputChange('telegram', e.target.value)} + placeholder="@username" + className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.telegram ? 'border-red-400' : ''}`} + /> + +
+ + {/* WhatsApp */} +
+ + handleInputChange('whatsapp', e.target.value)} + placeholder="+7 (999) 123-45-67" + className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.whatsapp ? 'border-red-400' : ''}`} + /> + +
+ + {/* Фото паспорта */} +
+ +
+
+
+ {formData.passportPhoto && formData.passportPhoto.trim() !== '' ? ( + Фото паспорта + ) : ( + Не загружено + )} +
+
+ +
+
+
+
+ + {/* Скрытые input для загрузки файлов */} + e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')} + className="hidden" + disabled={isUploadingAvatar} + /> + + e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')} + className="hidden" + disabled={isUploadingPassport} + /> +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/employees/employee-edit-inline-form.tsx b/src/components/employees/employee-edit-inline-form.tsx index ef09daa..8d86a00 100644 --- a/src/components/employees/employee-edit-inline-form.tsx +++ b/src/components/employees/employee-edit-inline-form.tsx @@ -1,28 +1,29 @@ "use client" import { useState, useRef } from 'react' -import Image from 'next/image' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Card } from '@/components/ui/card' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' -import { Separator } from '@/components/ui/separator' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { - Camera, User, - X, - Save, UserPen, - Phone, - Mail, - Briefcase, - DollarSign, - FileText, - MessageCircle + AlertCircle, + Save, + X, + Camera, + RefreshCw } from 'lucide-react' import { toast } from 'sonner' +import { + formatPhoneInput, + formatSalary, + formatNameInput, + isValidEmail, + isValidPhone, + isValidSalary, + isValidBirthDate +} from '@/lib/input-masks' interface Employee { id: string @@ -71,6 +72,10 @@ interface EmployeeEditInlineFormProps { isLoading?: boolean } +interface ValidationErrors { + [key: string]: string +} + export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = false }: EmployeeEditInlineFormProps) { // Функция для форматирования даты из ISO в YYYY-MM-DD const formatDateForInput = (dateString?: string) => { @@ -95,16 +100,125 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = passportPhoto: employee.passportPhoto || '' }) + const [errors, setErrors] = useState({}) const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) const [isUploadingPassport, setIsUploadingPassport] = useState(false) - const [showPassportPreview, setShowPassportPreview] = useState(false) const avatarInputRef = useRef(null) const passportInputRef = useRef(null) + const validateField = (field: string, value: string | number): string | null => { + switch (field) { + case 'firstName': + case 'lastName': + if (!value || String(value).trim() === '') { + return field === 'firstName' ? 'Имя обязательно' : 'Фамилия обязательна' + } + if (String(value).length < 2) { + return field === 'firstName' ? 'Имя минимум 2 символа' : 'Фамилия минимум 2 символа' + } + if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) { + return field === 'firstName' ? 'Только буквы, пробелы и дефисы' : 'Только буквы, пробелы и дефисы' + } + break + + case 'middleName': + if (value && String(value).length > 0) { + if (String(value).length < 2) { + return 'Отчество минимум 2 символа' + } + if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) { + return 'Только буквы, пробелы и дефисы' + } + } + break + + case 'position': + if (!value || String(value).trim() === '') { + return 'Должность обязательна' + } + if (String(value).length < 2) { + return 'Должность минимум 2 символа' + } + break + + case 'phone': + case 'whatsapp': + if (field === 'phone' && (!value || String(value).trim() === '')) { + return 'Телефон обязателен' + } + if (value && String(value).trim() !== '' && !isValidPhone(String(value))) { + return 'Некорректный формат телефона' + } + break + + case 'email': + if (value && String(value).trim() !== '' && !isValidEmail(String(value))) { + return 'Некорректный email' + } + break + + case 'birthDate': + if (value && String(value).trim() !== '') { + const validation = isValidBirthDate(String(value)) + if (!validation.valid) { + return validation.message || 'Некорректная дата рождения' + } + } + break + + case 'salary': + const salaryValidation = isValidSalary(Number(value)) + if (!salaryValidation.valid) { + return salaryValidation.message || 'Некорректная зарплата' + } + break + } + + return null + } + const handleInputChange = (field: string, value: string | number) => { + let processedValue = value + + // Применяем маски ввода + if (typeof value === 'string') { + switch (field) { + case 'phone': + case 'whatsapp': + processedValue = formatPhoneInput(value) + break + case 'firstName': + case 'lastName': + case 'middleName': + processedValue = formatNameInput(value) + break + } + } + setFormData(prev => ({ ...prev, - [field]: value + [field]: processedValue + })) + + // Валидация в реальном времени + const error = validateField(field, processedValue) + setErrors(prev => ({ + ...prev, + [field]: error || '' + })) + } + + const handleSalaryChange = (value: string) => { + const numericValue = parseInt(value.replace(/\D/g, '')) || 0 + setFormData(prev => ({ + ...prev, + salary: numericValue + })) + + const error = validateField('salary', numericValue) + setErrors(prev => ({ + ...prev, + salary: error || '' })) } @@ -119,10 +233,11 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = let endpoint: string if (type === 'avatar') { - formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`) + formDataUpload.append('userId', `temp_${Date.now()}`) endpoint = '/api/upload-avatar' } else { - formDataUpload.append('documentType', 'passport') + // Для фото паспорта используем специальный endpoint для документов + formDataUpload.append('documentType', 'passport-photo') endpoint = '/api/upload-employee-document' } @@ -138,45 +253,41 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = const result = await response.json() - if (!result.success) { - throw new Error(result.error || 'Неизвестная ошибка при загрузке') - } - setFormData(prev => ({ ...prev, [type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url })) - toast.success(`${type === 'avatar' ? 'Фото' : 'Паспорт'} успешно загружен`) + toast.success(`${type === 'avatar' ? 'Аватар' : 'Фото паспорта'} успешно загружен`) } catch (error) { console.error(`Error uploading ${type}:`, error) - const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'фото' : 'паспорта'}` + const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'аватара' : 'паспорта'}` toast.error(errorMessage) } finally { setLoading(false) } } - const formatPhoneInput = (value: string) => { - const cleaned = value.replace(/\D/g, '') - if (cleaned.length <= 1) return cleaned - if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}` - if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}` - if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}` - return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}` + const validateForm = (): boolean => { + const newErrors: ValidationErrors = {} + + // Валидируем все поля + Object.keys(formData).forEach(field => { + const error = validateField(field, formData[field as keyof typeof formData]) + if (error) { + newErrors[field] = error + } + }) + + setErrors(newErrors) + return Object.keys(newErrors).filter(key => newErrors[key]).length === 0 } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - // Валидация обязательных полей - if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) { - toast.error('Пожалуйста, заполните все обязательные поля') - return - } - - if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) { - toast.error('Введите корректный email адрес') + if (!validateForm()) { + toast.error('Исправьте ошибки в форме') return } @@ -185,12 +296,12 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = firstName: formData.firstName, lastName: formData.lastName, middleName: formData.middleName || undefined, - birthDate: formData.birthDate || undefined, phone: formData.phone, email: formData.email || undefined, position: formData.position, salary: formData.salary || undefined, avatar: formData.avatar || undefined, + birthDate: formData.birthDate || undefined, telegram: formData.telegram || undefined, whatsapp: formData.whatsapp || undefined, passportPhoto: formData.passportPhoto || undefined @@ -199,6 +310,17 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = onSave(employeeData) } + // Компонент для отображения ошибок + const ErrorMessage = ({ error }: { error: string }) => { + if (!error) return null + return ( +
+ + {error} +
+ ) + } + const getInitials = () => { const first = formData.firstName.charAt(0).toUpperCase() const last = formData.lastName.charAt(0).toUpperCase() @@ -206,361 +328,242 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = } return ( - - - - - Редактировать сотрудника: {employee.firstName} {employee.lastName} - - - - -
- {/* Фотографии */} -
- {/* Фото сотрудника */} -
- - -
- + + +
+ {/* Заголовок */} +
+ +

Редактирование сотрудника

+ ({employee.firstName} {employee.lastName}) +
+ +
+ {/* Аватар с возможностью загрузки */} +
+
+ {formData.avatar && formData.avatar.trim() !== '' ? ( - { - console.error('Ошибка загрузки аватара:', formData.avatar); - e.currentTarget.style.display = 'none'; - }} - onLoad={() => console.log('Аватар загружен успешно:', formData.avatar)} - /> + ) : null} - - {getInitials() || } + + {getInitials() || } - -
- e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')} - className="hidden" - /> - - - - {formData.avatar && ( - - )} +
+
- {/* Фото паспорта */} -
- - -
- {formData.passportPhoto && formData.passportPhoto.trim() !== '' ? ( -
- Паспорт setShowPassportPreview(true)} - /> - -
- Нажмите для увеличения -
-
- ) : ( -
-
- -

Паспорт не загружен

-

Рекомендуемый формат: JPG, PNG

-
-
- )} - - e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')} - className="hidden" - /> - - -
+ {/* Основные поля в одну строку */} +
+ {/* Имя */} +
+ handleInputChange('firstName', e.target.value)} + placeholder="Имя *" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.firstName ? 'border-red-400' : ''}`} + required + /> +
-
- - - - {/* Основная информация */} -
- -
-
- - handleInputChange('firstName', e.target.value)} - placeholder="Александр" - className="glass-input text-white placeholder:text-white/40" - required - /> -
- -
- - handleInputChange('lastName', e.target.value)} - placeholder="Петров" - className="glass-input text-white placeholder:text-white/40" - required - /> -
- -
- - handleInputChange('middleName', e.target.value)} - placeholder="Иванович" - className="glass-input text-white placeholder:text-white/40" - /> -
- -
- - handleInputChange('birthDate', e.target.value)} - className="glass-input text-white" - /> -
+ {/* Фамилия */} +
+ handleInputChange('lastName', e.target.value)} + placeholder="Фамилия *" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.lastName ? 'border-red-400' : ''}`} + required + /> +
-
- - - {/* Контактная информация */} -
- - -
-
- - { - const formatted = formatPhoneInput(e.target.value) - handleInputChange('phone', formatted) - }} - placeholder="+7 (999) 123-45-67" - className="glass-input text-white placeholder:text-white/40" - required - /> -
- -
- - handleInputChange('telegram', e.target.value)} - placeholder="@username" - className="glass-input text-white placeholder:text-white/40" - /> -
- -
- - { - const formatted = formatPhoneInput(e.target.value) - handleInputChange('whatsapp', formatted) - }} - placeholder="+7 (999) 123-45-67" - className="glass-input text-white placeholder:text-white/40" - /> -
- -
- - handleInputChange('email', e.target.value)} - placeholder="a.petrov@company.com" - className="glass-input text-white placeholder:text-white/40" - /> -
+ {/* Должность */} +
+ handleInputChange('position', e.target.value)} + placeholder="Должность *" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.position ? 'border-red-400' : ''}`} + required + /> +
-
- - - - {/* Рабочая информация */} -
- -
-
- - handleInputChange('position', e.target.value)} - placeholder="Менеджер склада" - className="glass-input text-white placeholder:text-white/40" - required - /> -
- -
- - handleInputChange('salary', parseInt(e.target.value) || 0)} - placeholder="80000" - className="glass-input text-white placeholder:text-white/40" - /> -
+ {/* Телефон */} +
+ handleInputChange('phone', e.target.value)} + placeholder="Телефон *" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.phone ? 'border-red-400' : ''}`} + required + /> + +
+ + {/* Email */} +
+ handleInputChange('email', e.target.value)} + placeholder="Email" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.email ? 'border-red-400' : ''}`} + /> + +
+ + {/* Зарплата */} +
+ handleSalaryChange(e.target.value)} + placeholder="Зарплата" + className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.salary ? 'border-red-400' : ''}`} + /> +
{/* Кнопки управления */} -
+
- - - - {/* Превью паспорта */} - - - - Фото паспорта - -
- {formData.passportPhoto && formData.passportPhoto.trim() !== '' && ( - Паспорт - )}
-
-
+ + {/* Дополнительные поля - всегда видимы */} +
+

+ + Дополнительная информация +

+ +
+ {/* Дата рождения */} +
+ + handleInputChange('birthDate', e.target.value)} + className={`glass-input text-white h-9 text-sm ${errors.birthDate ? 'border-red-400' : ''}`} + /> + +
+ + {/* Telegram */} +
+ + handleInputChange('telegram', e.target.value)} + placeholder="@username" + className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.telegram ? 'border-red-400' : ''}`} + /> + +
+ + {/* WhatsApp */} +
+ + handleInputChange('whatsapp', e.target.value)} + placeholder="+7 (999) 123-45-67" + className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.whatsapp ? 'border-red-400' : ''}`} + /> + +
+ + {/* Фото паспорта */} +
+ +
+
+
+ {formData.passportPhoto && formData.passportPhoto.trim() !== '' ? ( + Фото паспорта + ) : ( + Не загружено + )} +
+
+ +
+
+
+
+ + {/* Скрытые input для загрузки файлов */} + e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')} + className="hidden" + disabled={isUploadingAvatar} + /> + + e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')} + className="hidden" + disabled={isUploadingPassport} + /> +
+ ) -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/components/employees/employee-header.tsx b/src/components/employees/employee-header.tsx index 80c957d..241edbf 100644 --- a/src/components/employees/employee-header.tsx +++ b/src/components/employees/employee-header.tsx @@ -1,14 +1,21 @@ "use client" import { Button } from '@/components/ui/button' -import { Users, Plus } from 'lucide-react' +import { Users, Plus, Layout, LayoutGrid } from 'lucide-react' interface EmployeeHeaderProps { showAddForm: boolean + showCompactForm: boolean onToggleAddForm: () => void + onToggleFormType: () => void } -export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderProps) { +export function EmployeeHeader({ + showAddForm, + showCompactForm, + onToggleAddForm, + onToggleFormType +}: EmployeeHeaderProps) { return (
@@ -18,13 +25,37 @@ export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderP

Личные данные, табель работы и учет

- + +
+ {showAddForm && ( + + )} + + +
) } \ No newline at end of file diff --git a/src/components/employees/employee-row.tsx b/src/components/employees/employee-row.tsx new file mode 100644 index 0000000..8a94969 --- /dev/null +++ b/src/components/employees/employee-row.tsx @@ -0,0 +1,557 @@ +"use client" + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' + +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { + Edit, + UserX, + Phone, + Mail, + Calendar, + Briefcase, + MessageCircle, + User, + Clock, + CheckCircle, + Plane, + Heart, + Zap, + Activity +} from 'lucide-react' +import { EmployeeCalendar } from './employee-calendar' + +interface Employee { + id: string + firstName: string + lastName: string + middleName?: string + position: string + phone: string + email?: string + avatar?: string + hireDate: string + status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED' + salary?: number + address?: string + birthDate?: string + passportSeries?: string + passportNumber?: string + passportIssued?: string + passportDate?: string + emergencyContact?: string + emergencyPhone?: string + telegram?: string + whatsapp?: string + passportPhoto?: string + createdAt: string + updatedAt: string +} + +interface ScheduleRecord { + id: string + date: string + status: string + hoursWorked?: number + employee: { + id: string + } +} + +interface EmployeeRowProps { + employee: Employee + employeeSchedules: {[key: string]: ScheduleRecord[]} + currentYear: number + currentMonth: number + onEdit: (employee: Employee) => void + onDelete: (employeeId: string) => void + onDayStatusChange: (employeeId: string, day: number, currentStatus: string) => void + onDayUpdate: (employeeId: string, date: Date, data: { + status: string + hoursWorked?: number + overtimeHours?: number + notes?: string + }) => void + deletingEmployeeId: string | null +} + +export function EmployeeRow({ + employee, + employeeSchedules, + currentYear, + currentMonth, + onEdit, + onDelete, + onDayStatusChange, + onDayUpdate, + deletingEmployeeId +}: EmployeeRowProps) { + const [isExpanded, setIsExpanded] = useState(false) + + + + const formatSalary = (salary?: number) => { + if (!salary) return 'Не указана' + return new Intl.NumberFormat('ru-RU').format(salary) + ' ₽' + } + + const formatDate = (dateString?: string) => { + if (!dateString) return 'Не указана' + return new Date(dateString).toLocaleDateString('ru-RU') + } + + // Подсчет статистики сотрудника как в календаре + const calculateEmployeeStats = () => { + const stats = { + totalHours: 0, + workDays: 0, + vacation: 0, + sick: 0, + overtime: 0, + kpi: 0 + } + + // Получаем данные из employeeSchedules + const scheduleData = employeeSchedules[employee.id] || [] + + // Получаем количество дней в текущем месяце + const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate() + + // Проходим по всем дням месяца + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(currentYear, currentMonth, day) + const dateStr = date.toISOString().split('T')[0] + const dayOfWeek = date.getDay() + + // Ищем запись в БД для этого дня + const dayRecord = scheduleData.find(record => + record.date.split('T')[0] === dateStr + ) + + if (dayRecord) { + // Если есть запись в БД, используем её + switch (dayRecord.status) { + case 'WORK': + stats.workDays++ + stats.totalHours += dayRecord.hoursWorked || 0 + stats.overtime += dayRecord.overtimeHours || 0 + break + case 'VACATION': + stats.vacation++ + break + case 'SICK': + stats.sick++ + break + } + } else { + // Если записи нет, используем логику по умолчанию + if (dayOfWeek !== 0 && dayOfWeek !== 6) { // Не выходные + stats.workDays++ + stats.totalHours += 8 // По умолчанию 8 часов + } + } + } + + // Расчет KPI на основе реальных данных + const expectedWorkDays = Math.floor(daysInMonth * (5/7)) // Примерно 5 дней в неделю + const expectedHours = expectedWorkDays * 8 // 8 часов в день + + if (expectedHours > 0) { + // KPI = (фактические часы / ожидаемые часы) * 100 + // Учитываем также отсутствия по болезни (снижают KPI) и переработки (повышают) + const baseKPI = (stats.totalHours / expectedHours) * 100 + + // Штраф за больничные (каждый день -2%) + const sickPenalty = stats.sick * 2 + + // Бонус за переработки (каждый час +0.5%) + const overtimeBonus = stats.overtime * 0.5 + + // Итоговый KPI с ограничением от 0 до 100 + stats.kpi = Math.max(0, Math.min(100, Math.round(baseKPI - sickPenalty + overtimeBonus))) + } else { + stats.kpi = 0 + } + + return stats + } + + const employeeStats = calculateEmployeeStats() + + return ( + + {/* Компактная строка сотрудника */} +
setIsExpanded(!isExpanded)} + > + {/* Блок данных сотрудника - выделенный модуль */} +
+
+
+ {/* Аватар */} + + {employee.avatar ? ( + + ) : null} + + {employee.firstName.charAt(0)}{employee.lastName.charAt(0)} + + + + {/* ФИО, должность и телефон */} +
+

+ {employee.firstName} {employee.lastName} +

+

{employee.position}

+
+ + {employee.phone} +
+
+
+
+ + {/* Основная информация в строку */} +
+ + {/* Статистика табеля - красивые карточки */} +
+ {/* Часов */} +
+
+
+
+ + + + +
+ +
+
+
{employeeStats.totalHours}ч
+

Часов

+
+
+ + {/* Рабочих */} +
+
+
+
+ + + + +
+ +
+
+
{employeeStats.workDays}
+

Рабочих

+
+
+ + {/* Отпуск */} +
+
+
+
+ + + + +
+ +
+
+
{employeeStats.vacation}
+

Отпуск

+
+
+ + {/* Больничный */} +
+
+
+
+ + + + +
+ +
+
+
{employeeStats.sick}
+

Больничный

+
+
+ + {/* Переработка */} +
+
+
+
+ + + + +
+ +
+
+
{employeeStats.overtime}ч
+

Переработка

+
+
+ + {/* KPI */} +
+
+
+
+ + + + +
+ +
+
+
{employeeStats.kpi}%
+

KPI

+
+
+
+ +
+
+ + {/* Развернутая секция с табелем */} + {isExpanded && ( +
+ {/* Дополнительная информация и управление */} +
+
+
+ {/* Email */} +
+ + {employee.email || 'Не указан'} +
+ + {/* Зарплата */} +
+ + {formatSalary(employee.salary)} +
+ + {/* Дата приема */} +
+ + Принят: {formatDate(employee.hireDate)} +
+ + {/* Дата рождения */} + {employee.birthDate && ( +
+ + Родился: {formatDate(employee.birthDate)} +
+ )} + + {/* Telegram */} + {employee.telegram && ( +
+ + TG: {employee.telegram} +
+ )} + + {/* WhatsApp */} + {employee.whatsapp && ( +
+ + WA: {employee.whatsapp} +
+ )} +
+ + {/* Кнопки управления */} +
+ + + + + + + + + Уволить сотрудника? + + Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}? + Это действие нельзя отменить. + + + + + Отмена + + onDelete(employee.id)} + disabled={deletingEmployeeId === employee.id} + className="bg-red-600 hover:bg-red-700 text-white" + > + {deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'} + + + + +
+
+
+ + {/* Табель работы и статистика - компактно */} +
+ +
+
+ )} + {/* SVG градиенты для статистики */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} \ No newline at end of file diff --git a/src/components/employees/employee-search.tsx b/src/components/employees/employee-search.tsx index 8729964..2c21a0c 100644 --- a/src/components/employees/employee-search.tsx +++ b/src/components/employees/employee-search.tsx @@ -1,8 +1,6 @@ "use client" -import { Card } from '@/components/ui/card' import { Input } from '@/components/ui/input' -import { Search } from 'lucide-react' interface EmployeeSearchProps { searchQuery: string @@ -11,16 +9,11 @@ interface EmployeeSearchProps { export function EmployeeSearch({ searchQuery, onSearchChange }: EmployeeSearchProps) { return ( - -
- - onSearchChange(e.target.value)} - className="glass-input pl-10" - /> -
-
+ onSearchChange(e.target.value)} + className="glass-input h-10" + /> ) } \ No newline at end of file diff --git a/src/components/employees/employees-dashboard.tsx b/src/components/employees/employees-dashboard.tsx index 0938732..125d103 100644 --- a/src/components/employees/employees-dashboard.tsx +++ b/src/components/employees/employees-dashboard.tsx @@ -5,20 +5,22 @@ import { useQuery, useMutation } from '@apollo/client' import { apolloClient } from '@/lib/apollo-client' import { Sidebar } from '@/components/dashboard/sidebar' import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' + import { EmployeeInlineForm } from './employee-inline-form' +import { EmployeeCompactForm } from './employee-compact-form' import { EmployeeEditInlineForm } from './employee-edit-inline-form' -import { EmployeeHeader } from './employee-header' + import { EmployeeSearch } from './employee-search' import { EmployeeLegend } from './employee-legend' -import { MonthNavigation } from './month-navigation' import { EmployeeEmptyState } from './employee-empty-state' -import { EmployeeItem } from './employee-item' +import { EmployeeRow } from './employee-row' import { EmployeeReports } from './employee-reports' import { toast } from 'sonner' import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries' import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations' -import { Users, FileText } from 'lucide-react' +import { Users, FileText, Plus, Layout, LayoutGrid } from 'lucide-react' // Интерфейс сотрудника interface Employee { @@ -51,13 +53,15 @@ interface Employee { export function EmployeesDashboard() { const [searchQuery, setSearchQuery] = useState('') const [showAddForm, setShowAddForm] = useState(false) + const [showCompactForm, setShowCompactForm] = useState(true) // По умолчанию компактная форма const [showEditForm, setShowEditForm] = useState(false) const [createLoading, setCreateLoading] = useState(false) const [editingEmployee, setEditingEmployee] = useState(null) const [deletingEmployeeId, setDeletingEmployeeId] = useState(null) const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({}) - const [currentYear] = useState(new Date().getFullYear()) - const [currentMonth] = useState(new Date().getMonth()) + const [currentYear, setCurrentYear] = useState(new Date().getFullYear()) + const [currentMonth, setCurrentMonth] = useState(new Date().getMonth()) + const [activeTab, setActiveTab] = useState('combined') interface ScheduleRecord { id: string @@ -254,6 +258,71 @@ export function EmployeesDashboard() { } } + // Функция для обновления данных дня из модалки + const updateDayData = async (employeeId: string, date: Date, data: { + status: string + hoursWorked?: number + overtimeHours?: number + notes?: string + }) => { + try { + // Отправляем мутацию + await updateEmployeeSchedule({ + variables: { + input: { + employeeId: employeeId, + date: date.toISOString().split('T')[0], // YYYY-MM-DD формат + status: data.status, + hoursWorked: data.hoursWorked, + overtimeHours: data.overtimeHours, + notes: data.notes + } + } + }) + + // Обновляем локальное состояние + const dateStr = date.toISOString().split('T')[0] + + setEmployeeSchedules(prev => { + const currentSchedule = prev[employeeId] || [] + const existingRecordIndex = currentSchedule.findIndex(record => + record.date.split('T')[0] === dateStr + ) + + const newRecord = { + id: Date.now().toString(), // временный ID + date: date.toISOString(), + status: data.status, + hoursWorked: data.hoursWorked, + overtimeHours: data.overtimeHours, + notes: data.notes, + employee: { id: employeeId } + } + + let updatedSchedule + if (existingRecordIndex >= 0) { + // Обновляем существующую запись + updatedSchedule = [...currentSchedule] + updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord } + } else { + // Добавляем новую запись + updatedSchedule = [...currentSchedule, newRecord] + } + + return { + ...prev, + [employeeId]: updatedSchedule + } + }) + + toast.success('Данные дня обновлены') + + } catch (error) { + console.error('Error updating day data:', error) + toast.error('Ошибка при обновлении данных дня') + } + } + const exportToCSV = () => { const csvContent = [ ['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'], @@ -340,25 +409,84 @@ ${employees.map((emp: Employee) =>
- {/* Заголовок страницы */} - setShowAddForm(!showAddForm)} - /> + {/* Панель управления с улучшенным расположением */} + +
+ {/* Красивые табы слева */} + + + + Сотрудники + + + + Отчеты + + - {/* Поиск */} - + {/* Поиск и кнопки справа */} +
+ {/* Увеличенный поиск */} +
+ +
+ + {/* Кнопки управления */} + {showAddForm && ( + + )} + + +
+
{/* Форма добавления сотрудника */} {showAddForm && ( - setShowAddForm(false)} - isLoading={createLoading} - /> + showCompactForm ? ( + setShowAddForm(false)} + isLoading={createLoading} + /> + ) : ( + setShowAddForm(false)} + isLoading={createLoading} + /> + ) )} {/* Форма редактирования сотрудника */} @@ -374,72 +502,106 @@ ${employees.map((emp: Employee) => /> )} - {/* Основной контент с вкладками */} - - - - - Сотрудники и табель - - - - Отчеты - - - - - - {(() => { - const filteredEmployees = employees.filter((employee: Employee) => - `${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) || - employee.position.toLowerCase().includes(searchQuery.toLowerCase()) - ) - - if (filteredEmployees.length === 0) { - return ( - setShowAddForm(true)} - /> - ) - } + {/* Контент табов */} + + + {(() => { + const filteredEmployees = employees.filter((employee: Employee) => + `${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) || + employee.position.toLowerCase().includes(searchQuery.toLowerCase()) + ) + if (filteredEmployees.length === 0) { return ( -
- {/* Навигация по месяцам */} - + setShowAddForm(true)} + /> + ) + } - {/* Легенда статусов */} + return ( +
+ {/* Навигация по месяцам и легенда в одной строке */} +
+
+

+ {new Date().toLocaleDateString('ru-RU', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })} +

+ + {/* Кнопки навигации */} +
+ + + +
+
+ + {/* Легенда статусов справа */} +
- {/* Объединенный список сотрудников с табелем */} - {filteredEmployees.map((employee: Employee) => ( - + {/* Компактный список сотрудников с раскрывающимся табелем */} +
+ {filteredEmployees.map((employee: Employee, index: number) => ( +
+ +
))}
- ) - })()} - - +
+ ) + })()} + +