Обновлен компонент панели управления сотрудниками: добавлены новые компоненты для заголовка, поиска, легенды статусов и состояния пустого списка. Оптимизирована логика отображения сотрудников и их табелей. Удалены неиспользуемые импорты и код для улучшения читаемости.
This commit is contained in:
161
src/components/employees/employee-calendar.tsx
Normal file
161
src/components/employees/employee-calendar.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Calendar, CheckCircle, Clock, Plane, Activity, XCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ScheduleRecord {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
status: string
|
||||||
|
hoursWorked?: number
|
||||||
|
employee: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmployeeCalendarProps {
|
||||||
|
employeeId: string
|
||||||
|
employeeSchedules: {[key: string]: ScheduleRecord[]}
|
||||||
|
currentYear: number
|
||||||
|
currentMonth: number
|
||||||
|
onDayStatusChange: (employeeId: string, day: number, currentStatus: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeCalendar({
|
||||||
|
employeeId,
|
||||||
|
employeeSchedules,
|
||||||
|
currentYear,
|
||||||
|
currentMonth,
|
||||||
|
onDayStatusChange
|
||||||
|
}: EmployeeCalendarProps) {
|
||||||
|
// Получаем количество дней в месяце
|
||||||
|
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
|
||||||
|
|
||||||
|
// Функция для получения статуса дня
|
||||||
|
const getDayStatus = (day: number) => {
|
||||||
|
const date = new Date(currentYear, currentMonth, day)
|
||||||
|
const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD
|
||||||
|
|
||||||
|
// Ищем запись в табеле для этого дня
|
||||||
|
const scheduleData = employeeSchedules[employeeId] || []
|
||||||
|
const dayRecord = scheduleData.find(record =>
|
||||||
|
record.date.split('T')[0] === dateStr
|
||||||
|
)
|
||||||
|
|
||||||
|
if (dayRecord) {
|
||||||
|
return dayRecord.status.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если записи нет, устанавливаем дефолтный статус
|
||||||
|
const dayOfWeek = date.getDay()
|
||||||
|
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
|
||||||
|
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 getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'work':
|
||||||
|
return <CheckCircle className="h-3 w-3" />
|
||||||
|
case 'weekend':
|
||||||
|
return <Clock className="h-3 w-3" />
|
||||||
|
case 'vacation':
|
||||||
|
return <Plane className="h-3 w-3" />
|
||||||
|
case 'sick':
|
||||||
|
return <Activity className="h-3 w-3" />
|
||||||
|
case 'absent':
|
||||||
|
return <XCircle className="h-3 w-3" />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем массив дней календаря
|
||||||
|
const calendarDays: (number | null)[] = []
|
||||||
|
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay()
|
||||||
|
const startOffset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1
|
||||||
|
|
||||||
|
// Добавляем пустые ячейки для выравнивания первой недели
|
||||||
|
for (let i = 0; i < startOffset; i++) {
|
||||||
|
calendarDays.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем дни месяца
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
calendarDays.push(day)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Табель работы за {new Date(currentYear, currentMonth).toLocaleDateString('ru-RU', { month: 'long' })}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Сетка календаря */}
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{/* Заголовки дней недели */}
|
||||||
|
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
|
||||||
|
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Дни месяца */}
|
||||||
|
{calendarDays.map((day, index) => {
|
||||||
|
if (day === null) {
|
||||||
|
return <div key={`empty-${index}`} className="p-2"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getDayStatus(day)
|
||||||
|
const hours = status === 'work' ? 8 : status === 'vacation' || status === 'sick' ? 8 : 0
|
||||||
|
const isToday = new Date().getDate() === day &&
|
||||||
|
new Date().getMonth() === currentMonth &&
|
||||||
|
new Date().getFullYear() === currentYear
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${employeeId}-${day}`}
|
||||||
|
className={`
|
||||||
|
relative p-2 min-h-[60px] border rounded-lg cursor-pointer
|
||||||
|
transition-transform duration-150 hover:scale-105 active:scale-95
|
||||||
|
${getCellStyle(status)}
|
||||||
|
${isToday ? 'ring-2 ring-white/50' : ''}
|
||||||
|
`}
|
||||||
|
onClick={() => onDayStatusChange(employeeId, day, status)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
{getStatusIcon(status)}
|
||||||
|
<span className="font-semibold text-sm">{day}</span>
|
||||||
|
</div>
|
||||||
|
{hours > 0 && (
|
||||||
|
<span className="text-xs opacity-80">{hours}ч</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isToday && (
|
||||||
|
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
200
src/components/employees/employee-card.tsx
Normal file
200
src/components/employees/employee-card.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
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,
|
||||||
|
MapPin,
|
||||||
|
AlertCircle,
|
||||||
|
MessageCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
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 EmployeeCardProps {
|
||||||
|
employee: Employee
|
||||||
|
onEdit: (employee: Employee) => void
|
||||||
|
onDelete: (employeeId: string) => void
|
||||||
|
deletingEmployeeId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeCard({ employee, onEdit, onDelete, deletingEmployeeId }: EmployeeCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:w-80 flex-shrink-0">
|
||||||
|
<div className="flex items-start space-x-4 mb-4">
|
||||||
|
<Avatar className="h-16 w-16 ring-2 ring-white/20">
|
||||||
|
{employee.avatar ? (
|
||||||
|
<AvatarImage
|
||||||
|
src={employee.avatar}
|
||||||
|
alt={`${employee.firstName} ${employee.lastName}`}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Ошибка загрузки аватара:', employee.avatar);
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
onLoad={() => console.log('Аватар загружен успешно:', employee.avatar)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold text-lg">
|
||||||
|
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-white font-semibold text-lg truncate">
|
||||||
|
{employee.firstName} {employee.lastName}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
|
||||||
|
onClick={() => onEdit(employee)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<UserX className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent className="glass-card border-white/10">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-white/70">
|
||||||
|
Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}?
|
||||||
|
Это действие нельзя отменить.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
|
||||||
|
Отмена
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onDelete(employee.id)}
|
||||||
|
disabled={deletingEmployeeId === employee.id}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
{deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-white font-semibold text-base">
|
||||||
|
{employee.firstName} {employee.middleName} {employee.lastName}
|
||||||
|
</h4>
|
||||||
|
<p className="text-purple-300 font-medium">{employee.position}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{/* Основные контакты */}
|
||||||
|
<div className="flex items-center text-white/70">
|
||||||
|
<Phone className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate">{employee.phone}</span>
|
||||||
|
</div>
|
||||||
|
{employee.email && (
|
||||||
|
<div className="flex items-center text-white/70">
|
||||||
|
<Mail className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate">{employee.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Дата рождения */}
|
||||||
|
{employee.birthDate && (
|
||||||
|
<div className="flex items-center text-white/70">
|
||||||
|
<Calendar className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
Родился: {new Date(employee.birthDate).toLocaleDateString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Дата приема на работу */}
|
||||||
|
<div className="flex items-center text-white/70">
|
||||||
|
<Briefcase className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
Принят: {new Date(employee.hireDate).toLocaleDateString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Адрес */}
|
||||||
|
{employee.address && (
|
||||||
|
<div className="flex items-center text-white/70">
|
||||||
|
<MapPin className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate">{employee.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Экстренный контакт */}
|
||||||
|
{employee.emergencyContact && (
|
||||||
|
<div className="flex items-center text-white/70">
|
||||||
|
<AlertCircle className="h-3 w-3 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
Экстр. контакт: {employee.emergencyContact}
|
||||||
|
{employee.emergencyPhone && ` (${employee.emergencyPhone})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Мессенджеры */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{employee.telegram && (
|
||||||
|
<div className="flex items-center text-blue-400">
|
||||||
|
<MessageCircle className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||||
|
<span className="truncate text-xs">@{employee.telegram}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{employee.whatsapp && (
|
||||||
|
<div className="flex items-center text-green-400">
|
||||||
|
<Phone className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||||
|
<span className="truncate text-xs">{employee.whatsapp}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
39
src/components/employees/employee-empty-state.tsx
Normal file
39
src/components/employees/employee-empty-state.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Users, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface EmployeeEmptyStateProps {
|
||||||
|
searchQuery: string
|
||||||
|
onShowAddForm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeEmptyState({ searchQuery, onShowAddForm }: EmployeeEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Users className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">
|
||||||
|
{searchQuery ? 'Сотрудники не найдены' : 'У вас пока нет сотрудников'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/60 text-sm mb-4">
|
||||||
|
{searchQuery
|
||||||
|
? 'Попробуйте изменить критерии поиска'
|
||||||
|
: 'Добавьте первого сотрудника в вашу команду'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{!searchQuery && (
|
||||||
|
<Button
|
||||||
|
onClick={onShowAddForm}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить сотрудника
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
30
src/components/employees/employee-header.tsx
Normal file
30
src/components/employees/employee-header.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Users, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface EmployeeHeaderProps {
|
||||||
|
showAddForm: boolean
|
||||||
|
onToggleAddForm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="h-8 w-8 text-purple-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Управление сотрудниками</h1>
|
||||||
|
<p className="text-white/70">Личные данные, табель работы и учет</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onToggleAddForm}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
96
src/components/employees/employee-item.tsx
Normal file
96
src/components/employees/employee-item.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { EmployeeCard } from './employee-card'
|
||||||
|
import { EmployeeCalendar } from './employee-calendar'
|
||||||
|
import { EmployeeStats } from './employee-stats'
|
||||||
|
|
||||||
|
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 EmployeeItemProps {
|
||||||
|
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
|
||||||
|
deletingEmployeeId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeItem({
|
||||||
|
employee,
|
||||||
|
employeeSchedules,
|
||||||
|
currentYear,
|
||||||
|
currentMonth,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDayStatusChange,
|
||||||
|
deletingEmployeeId
|
||||||
|
}: EmployeeItemProps) {
|
||||||
|
return (
|
||||||
|
<Card key={employee.id} className="glass-card p-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
{/* Информация о сотруднике */}
|
||||||
|
<EmployeeCard
|
||||||
|
employee={employee}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
deletingEmployeeId={deletingEmployeeId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Табель работы и статистика */}
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<EmployeeCalendar
|
||||||
|
employeeId={employee.id}
|
||||||
|
employeeSchedules={employeeSchedules}
|
||||||
|
currentYear={currentYear}
|
||||||
|
currentMonth={currentMonth}
|
||||||
|
onDayStatusChange={onDayStatusChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Статистика за месяц */}
|
||||||
|
<EmployeeStats
|
||||||
|
currentYear={currentYear}
|
||||||
|
currentMonth={currentMonth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
40
src/components/employees/employee-legend.tsx
Normal file
40
src/components/employees/employee-legend.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { CheckCircle, Clock, Plane, Activity, XCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
export function EmployeeLegend() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80 flex items-center justify-center">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Рабочий день</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-purple-500/20 text-purple-300/70 border-purple-400/80 flex items-center justify-center">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Выходной</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-blue-500/20 text-blue-300/70 border-blue-400/80 flex items-center justify-center">
|
||||||
|
<Plane className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Отпуск</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80 flex items-center justify-center">
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Больничный</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded border bg-red-500/20 text-red-300/70 border-red-400/80 flex items-center justify-center">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70 text-sm">Прогул</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
191
src/components/employees/employee-reports.tsx
Normal file
191
src/components/employees/employee-reports.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
FileText,
|
||||||
|
Calendar,
|
||||||
|
Download,
|
||||||
|
Plus
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
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 EmployeeReportsProps {
|
||||||
|
employees: Employee[]
|
||||||
|
onShowAddForm: () => void
|
||||||
|
onExportCSV: () => void
|
||||||
|
onGenerateReport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeReports({ employees, onShowAddForm, onExportCSV, onGenerateReport }: EmployeeReportsProps) {
|
||||||
|
if (employees.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<BarChart3 className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Нет данных для отчетов</h3>
|
||||||
|
<p className="text-white/60 text-sm mb-4">
|
||||||
|
Добавьте сотрудников, чтобы генерировать отчеты и аналитику
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={onShowAddForm}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить сотрудника
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Статистические карточки */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-sm">Всего сотрудников</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{employees.length}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="h-8 w-8 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-sm">Активных</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">
|
||||||
|
{employees.filter((e: Employee) => e.status === 'ACTIVE').length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BarChart3 className="h-8 w-8 text-green-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-sm">Средняя зарплата</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-400">
|
||||||
|
{employees.length > 0
|
||||||
|
? Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length).toLocaleString('ru-RU')
|
||||||
|
: '0'} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FileText className="h-8 w-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/70 text-sm">Отделов</p>
|
||||||
|
<p className="text-2xl font-bold text-orange-400">
|
||||||
|
{new Set(employees.map((e: Employee) => e.position).filter(Boolean)).size}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Calendar className="h-8 w-8 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Экспорт отчетов */}
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<h3 className="text-white font-medium mb-4 flex items-center gap-2">
|
||||||
|
<Download className="h-5 w-5" />
|
||||||
|
Экспорт отчетов
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-white/80 font-medium">Детальный отчет (CSV)</h4>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
Полная информация о всех сотрудниках в формате таблицы для Excel/Google Sheets
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={onExportCSV}
|
||||||
|
className="w-full glass-button"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Скачать CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-white/80 font-medium">Сводный отчет (TXT)</h4>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
Краткая статистика и список сотрудников в текстовом формате
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={onGenerateReport}
|
||||||
|
className="w-full glass-button"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
Создать отчет
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Аналитика по отделам */}
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<h3 className="text-white font-medium mb-4">Распределение по отделам</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from(new Set(employees.map((e: Employee) => e.position).filter(Boolean)) as Set<string>).map((position: string) => {
|
||||||
|
const positionEmployees = employees.filter((e: Employee) => e.position === position)
|
||||||
|
const percentage = Math.round((positionEmployees.length / employees.length) * 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={position} className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-white/80 text-sm">{position}</span>
|
||||||
|
<span className="text-white/60 text-xs">{positionEmployees.length} чел. ({percentage}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
26
src/components/employees/employee-search.tsx
Normal file
26
src/components/employees/employee-search.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
|
interface EmployeeSearchProps {
|
||||||
|
searchQuery: string
|
||||||
|
onSearchChange: (query: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeSearch({ searchQuery, onSearchChange }: EmployeeSearchProps) {
|
||||||
|
return (
|
||||||
|
<Card className="glass-card p-4 mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/60 h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск сотрудников..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="glass-input pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
76
src/components/employees/employee-stats.tsx
Normal file
76
src/components/employees/employee-stats.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
interface EmployeeStatsProps {
|
||||||
|
currentYear: number
|
||||||
|
currentMonth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmployeeStats({ currentYear, currentMonth }: EmployeeStatsProps) {
|
||||||
|
// Генерируем дни месяца для подсчета статистики
|
||||||
|
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
|
||||||
|
|
||||||
|
// Создаем мок-статистику для демонстрации
|
||||||
|
const generateDayStatus = (day: number) => {
|
||||||
|
const date = new Date(currentYear, currentMonth, day)
|
||||||
|
const dayOfWeek = date.getDay()
|
||||||
|
|
||||||
|
// Выходные
|
||||||
|
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
|
||||||
|
|
||||||
|
// Некоторые случайные отпуска/больничные для демонстрации
|
||||||
|
if ([15, 16].includes(day)) return 'vacation'
|
||||||
|
if ([10].includes(day)) return 'sick'
|
||||||
|
if ([22].includes(day)) return 'absent'
|
||||||
|
|
||||||
|
return 'work'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем статистику
|
||||||
|
const stats = {
|
||||||
|
workDays: 0,
|
||||||
|
vacationDays: 0,
|
||||||
|
sickDays: 0,
|
||||||
|
absentDays: 0,
|
||||||
|
totalHours: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const status = generateDayStatus(day)
|
||||||
|
switch (status) {
|
||||||
|
case 'work':
|
||||||
|
stats.workDays++
|
||||||
|
stats.totalHours += 8
|
||||||
|
break
|
||||||
|
case 'vacation':
|
||||||
|
stats.vacationDays++
|
||||||
|
break
|
||||||
|
case 'sick':
|
||||||
|
stats.sickDays++
|
||||||
|
break
|
||||||
|
case 'absent':
|
||||||
|
stats.absentDays++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-3 mt-4">
|
||||||
|
<div className="text-center p-3 bg-white/10 rounded-lg">
|
||||||
|
<p className="text-emerald-200 font-semibold text-lg">{stats.workDays}</p>
|
||||||
|
<p className="text-white/60 text-xs">Рабочих дней</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/10 rounded-lg">
|
||||||
|
<p className="text-sky-200 font-semibold text-lg">{stats.vacationDays}</p>
|
||||||
|
<p className="text-white/60 text-xs">Отпуск</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/10 rounded-lg">
|
||||||
|
<p className="text-amber-200 font-semibold text-lg">{stats.sickDays}</p>
|
||||||
|
<p className="text-white/60 text-xs">Больничный</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-white font-semibold text-lg">{stats.totalHours}ч</p>
|
||||||
|
<p className="text-white/60 text-xs">Всего часов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -5,39 +5,20 @@ import { useQuery, useMutation } from '@apollo/client'
|
|||||||
import { apolloClient } from '@/lib/apollo-client'
|
import { apolloClient } from '@/lib/apollo-client'
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
|
||||||
// import { EmployeeForm } from './employee-form'
|
|
||||||
import { EmployeeInlineForm } from './employee-inline-form'
|
import { EmployeeInlineForm } from './employee-inline-form'
|
||||||
import { EmployeeEditInlineForm } from './employee-edit-inline-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 { EmployeeReports } from './employee-reports'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
|
||||||
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
|
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
|
||||||
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
||||||
import {
|
import { Users, FileText } from 'lucide-react'
|
||||||
Users,
|
|
||||||
Calendar,
|
|
||||||
Search,
|
|
||||||
Plus,
|
|
||||||
FileText,
|
|
||||||
Edit,
|
|
||||||
UserX,
|
|
||||||
Phone,
|
|
||||||
Mail,
|
|
||||||
Download,
|
|
||||||
BarChart3,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
Plane,
|
|
||||||
Activity,
|
|
||||||
Clock,
|
|
||||||
Briefcase,
|
|
||||||
MapPin,
|
|
||||||
AlertCircle,
|
|
||||||
MessageCircle
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
// Интерфейс сотрудника
|
// Интерфейс сотрудника
|
||||||
interface Employee {
|
interface Employee {
|
||||||
@ -360,37 +341,16 @@ ${employees.map((emp: Employee) =>
|
|||||||
<main className="flex-1 ml-56 p-6">
|
<main className="flex-1 ml-56 p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Заголовок страницы */}
|
{/* Заголовок страницы */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<EmployeeHeader
|
||||||
<div className="flex items-center gap-3">
|
showAddForm={showAddForm}
|
||||||
<Users className="h-8 w-8 text-purple-400" />
|
onToggleAddForm={() => setShowAddForm(!showAddForm)}
|
||||||
<div>
|
/>
|
||||||
<h1 className="text-2xl font-bold text-white">Управление сотрудниками</h1>
|
|
||||||
<p className="text-white/70">Личные данные, табель работы и учет</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
|
||||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
{showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Поиск */}
|
{/* Поиск */}
|
||||||
<Card className="glass-card p-4 mb-6">
|
<EmployeeSearch
|
||||||
<div className="relative">
|
searchQuery={searchQuery}
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/60 h-4 w-4" />
|
onSearchChange={setSearchQuery}
|
||||||
<Input
|
/>
|
||||||
placeholder="Поиск сотрудников..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="glass-input pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Форма добавления сотрудника */}
|
{/* Форма добавления сотрудника */}
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
@ -443,602 +403,51 @@ ${employees.map((emp: Employee) =>
|
|||||||
|
|
||||||
if (filteredEmployees.length === 0) {
|
if (filteredEmployees.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<EmployeeEmptyState
|
||||||
<div className="text-center">
|
searchQuery={searchQuery}
|
||||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
onShowAddForm={() => setShowAddForm(true)}
|
||||||
<Users className="h-8 w-8 text-white/40" />
|
/>
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-white mb-2">
|
|
||||||
{searchQuery ? 'Сотрудники не найдены' : 'У вас пока нет сотрудников'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-white/60 text-sm mb-4">
|
|
||||||
{searchQuery
|
|
||||||
? 'Попробуйте изменить критерии поиска'
|
|
||||||
: 'Добавьте первого сотрудника в вашу команду'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
{!searchQuery && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowAddForm(true)}
|
|
||||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Добавить сотрудника
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Навигация по месяцам */}
|
{/* Навигация по месяцам */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<MonthNavigation
|
||||||
<h3 className="text-white font-medium text-lg">Январь 2024</h3>
|
currentYear={currentYear}
|
||||||
<div className="flex gap-2">
|
currentMonth={currentMonth}
|
||||||
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
/>
|
||||||
← Декабрь
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
|
||||||
Сегодня
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
|
||||||
Февраль →
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Легенда точно как в гите */}
|
{/* Легенда статусов */}
|
||||||
<div className="flex flex-wrap gap-3">
|
<EmployeeLegend />
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded border bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80 flex items-center justify-center">
|
|
||||||
<CheckCircle className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span className="text-white/70 text-sm">Рабочий день</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded border bg-purple-500/20 text-purple-300/70 border-purple-400/80 flex items-center justify-center">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span className="text-white/70 text-sm">Выходной</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded border bg-blue-500/20 text-blue-300/70 border-blue-400/80 flex items-center justify-center">
|
|
||||||
<Plane className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span className="text-white/70 text-sm">Отпуск</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded border bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80 flex items-center justify-center">
|
|
||||||
<Activity className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span className="text-white/70 text-sm">Больничный</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded border bg-red-500/20 text-red-300/70 border-red-400/80 flex items-center justify-center">
|
|
||||||
<XCircle className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span className="text-white/70 text-sm">Прогул</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Объединенный список сотрудников с табелем */}
|
{/* Объединенный список сотрудников с табелем */}
|
||||||
{filteredEmployees.map((employee: Employee) => {
|
{filteredEmployees.map((employee: Employee) => (
|
||||||
// Генерируем календарные дни для текущего месяца
|
<EmployeeItem
|
||||||
const currentDate = new Date()
|
key={employee.id}
|
||||||
const currentMonth = currentDate.getMonth()
|
employee={employee}
|
||||||
const currentYear = currentDate.getFullYear()
|
employeeSchedules={employeeSchedules}
|
||||||
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
|
currentYear={currentYear}
|
||||||
|
currentMonth={currentMonth}
|
||||||
// Создаем массив дней с моковыми данными табеля
|
onEdit={handleEditEmployee}
|
||||||
const generateDayStatus = (day: number) => {
|
onDelete={handleEmployeeDeleted}
|
||||||
const date = new Date(currentYear, currentMonth, day)
|
onDayStatusChange={changeDayStatus}
|
||||||
const dayOfWeek = date.getDay()
|
deletingEmployeeId={deletingEmployeeId}
|
||||||
|
/>
|
||||||
// Выходные
|
))}
|
||||||
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
|
|
||||||
|
|
||||||
// Некоторые случайные отпуска/больничные для демонстрации
|
|
||||||
if ([15, 16].includes(day)) return 'vacation'
|
|
||||||
if ([10].includes(day)) return 'sick'
|
|
||||||
if ([22].includes(day)) return 'absent'
|
|
||||||
|
|
||||||
return 'work'
|
|
||||||
}
|
|
||||||
|
|
||||||
// const getDayClass = (status: string) => {
|
|
||||||
// switch (status) {
|
|
||||||
// case 'work': return 'bg-green-500/30 border-green-500/50'
|
|
||||||
// case 'weekend': return 'bg-gray-500/30 border-gray-500/50'
|
|
||||||
// case 'vacation': return 'bg-blue-500/30 border-blue-500/50'
|
|
||||||
// case 'sick': return 'bg-yellow-500/30 border-yellow-500/50'
|
|
||||||
// case 'absent': return 'bg-red-500/30 border-red-500/50'
|
|
||||||
// default: return 'bg-white/10 border-white/20'
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Подсчитываем статистику
|
|
||||||
const stats = {
|
|
||||||
workDays: 0,
|
|
||||||
vacationDays: 0,
|
|
||||||
sickDays: 0,
|
|
||||||
absentDays: 0,
|
|
||||||
totalHours: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
const status = generateDayStatus(day)
|
|
||||||
switch (status) {
|
|
||||||
case 'work':
|
|
||||||
stats.workDays++
|
|
||||||
stats.totalHours += 8
|
|
||||||
break
|
|
||||||
case 'vacation':
|
|
||||||
stats.vacationDays++
|
|
||||||
break
|
|
||||||
case 'sick':
|
|
||||||
stats.sickDays++
|
|
||||||
break
|
|
||||||
case 'absent':
|
|
||||||
stats.absentDays++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={employee.id} className="glass-card p-6">
|
|
||||||
<div className="flex flex-col lg:flex-row gap-6">
|
|
||||||
{/* Информация о сотруднике */}
|
|
||||||
<div className="lg:w-80 flex-shrink-0">
|
|
||||||
<div className="flex items-start space-x-4 mb-4">
|
|
||||||
<Avatar className="h-16 w-16 ring-2 ring-white/20">
|
|
||||||
{employee.avatar ? (
|
|
||||||
<AvatarImage
|
|
||||||
src={employee.avatar}
|
|
||||||
alt={`${employee.firstName} ${employee.lastName}`}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error('Ошибка загрузки аватара:', employee.avatar);
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
}}
|
|
||||||
onLoad={() => console.log('Аватар загружен успешно:', employee.avatar)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold text-lg">
|
|
||||||
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className="text-white font-semibold text-lg truncate">
|
|
||||||
{employee.firstName} {employee.lastName}
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
|
|
||||||
onClick={() => handleEditEmployee(employee)}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<UserX className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent className="glass-card border-white/10">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription className="text-white/70">
|
|
||||||
Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}?
|
|
||||||
Это действие нельзя отменить.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
|
|
||||||
Отмена
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => handleEmployeeDeleted(employee.id)}
|
|
||||||
disabled={deletingEmployeeId === employee.id}
|
|
||||||
className="bg-red-600 hover:bg-red-700 text-white"
|
|
||||||
>
|
|
||||||
{deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<h4 className="text-white font-semibold text-base">
|
|
||||||
{employee.firstName} {employee.middleName} {employee.lastName}
|
|
||||||
</h4>
|
|
||||||
<p className="text-purple-300 font-medium">{employee.position}</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{/* Основные контакты */}
|
|
||||||
<div className="flex items-center text-white/70">
|
|
||||||
<Phone className="h-3 w-3 mr-2 flex-shrink-0" />
|
|
||||||
<span className="truncate">{employee.phone}</span>
|
|
||||||
</div>
|
|
||||||
{employee.email && (
|
|
||||||
<div className="flex items-center text-white/70">
|
|
||||||
<Mail className="h-3 w-3 mr-2 flex-shrink-0" />
|
|
||||||
<span className="truncate">{employee.email}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Дата рождения */}
|
|
||||||
{employee.birthDate && (
|
|
||||||
<div className="flex items-center text-white/70">
|
|
||||||
<Calendar className="h-3 w-3 mr-2 flex-shrink-0" />
|
|
||||||
<span className="truncate">
|
|
||||||
Родился: {new Date(employee.birthDate).toLocaleDateString('ru-RU')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Дата приема на работу */}
|
|
||||||
<div className="flex items-center text-white/70">
|
|
||||||
<Briefcase className="h-3 w-3 mr-2 flex-shrink-0" />
|
|
||||||
<span className="truncate">
|
|
||||||
Принят: {new Date(employee.hireDate).toLocaleDateString('ru-RU')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Адрес */}
|
|
||||||
{employee.address && (
|
|
||||||
<div className="flex items-center text-white/70">
|
|
||||||
<MapPin className="h-3 w-3 mr-2 flex-shrink-0" />
|
|
||||||
<span className="truncate">{employee.address}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Экстренный контакт */}
|
|
||||||
{employee.emergencyContact && (
|
|
||||||
<div className="flex items-center text-white/70">
|
|
||||||
<AlertCircle className="h-3 w-3 mr-2 flex-shrink-0" />
|
|
||||||
<span className="truncate">
|
|
||||||
Экстр. контакт: {employee.emergencyContact}
|
|
||||||
{employee.emergencyPhone && ` (${employee.emergencyPhone})`}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Мессенджеры */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{employee.telegram && (
|
|
||||||
<div className="flex items-center text-blue-400">
|
|
||||||
<MessageCircle className="h-3 w-3 mr-1 flex-shrink-0" />
|
|
||||||
<span className="truncate text-xs">@{employee.telegram}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{employee.whatsapp && (
|
|
||||||
<div className="flex items-center text-green-400">
|
|
||||||
<Phone className="h-3 w-3 mr-1 flex-shrink-0" />
|
|
||||||
<span className="truncate text-xs">{employee.whatsapp}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Табель работы и статистика */}
|
|
||||||
<div className="flex-1 space-y-4">
|
|
||||||
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{/* Сетка календаря - точно как в гите */}
|
|
||||||
<div className="grid grid-cols-7 gap-2">
|
|
||||||
{/* Заголовки дней недели */}
|
|
||||||
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
|
|
||||||
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Дни месяца */}
|
|
||||||
{(() => {
|
|
||||||
// Точная логика из гита
|
|
||||||
const calendarDays: (number | null)[] = []
|
|
||||||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay()
|
|
||||||
const startOffset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1
|
|
||||||
|
|
||||||
// Добавляем пустые ячейки для выравнивания первой недели
|
|
||||||
for (let i = 0; i < startOffset; i++) {
|
|
||||||
calendarDays.push(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем дни месяца
|
|
||||||
for (let day = 1; day <= daysInMonth; day++) {
|
|
||||||
calendarDays.push(day)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Функция для получения статуса дня из данных или дефолта
|
|
||||||
const getDayStatus = (day: number) => {
|
|
||||||
const date = new Date(currentYear, currentMonth, day)
|
|
||||||
const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD
|
|
||||||
|
|
||||||
// Ищем запись в табеле для этого дня
|
|
||||||
const scheduleData = employeeSchedules[employee.id] || []
|
|
||||||
const dayRecord = scheduleData.find(record =>
|
|
||||||
record.date.split('T')[0] === dateStr
|
|
||||||
)
|
|
||||||
|
|
||||||
if (dayRecord) {
|
|
||||||
return dayRecord.status.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если записи нет, устанавливаем дефолтный статус
|
|
||||||
const dayOfWeek = date.getDay()
|
|
||||||
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
|
|
||||||
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 getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'work':
|
|
||||||
return <CheckCircle className="h-3 w-3" />
|
|
||||||
case 'weekend':
|
|
||||||
return <Clock className="h-3 w-3" />
|
|
||||||
case 'vacation':
|
|
||||||
return <Plane className="h-3 w-3" />
|
|
||||||
case 'sick':
|
|
||||||
return <Activity className="h-3 w-3" />
|
|
||||||
case 'absent':
|
|
||||||
return <XCircle className="h-3 w-3" />
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return calendarDays.map((day, index) => {
|
|
||||||
if (day === null) {
|
|
||||||
return <div key={`empty-${index}`} className="p-2"></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = getDayStatus(day)
|
|
||||||
const hours = status === 'work' ? 8 : status === 'vacation' || status === 'sick' ? 8 : 0
|
|
||||||
const isToday = new Date().getDate() === day &&
|
|
||||||
new Date().getMonth() === currentMonth &&
|
|
||||||
new Date().getFullYear() === currentYear
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${employee.id}-${day}`}
|
|
||||||
className={`
|
|
||||||
relative p-2 min-h-[60px] border rounded-lg cursor-pointer
|
|
||||||
transition-transform duration-150 hover:scale-105 active:scale-95
|
|
||||||
${getCellStyle(status)}
|
|
||||||
${isToday ? 'ring-2 ring-white/50' : ''}
|
|
||||||
`}
|
|
||||||
onClick={() => {
|
|
||||||
// Циклично переключаем статусы
|
|
||||||
changeDayStatus(employee.id, day, status)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
|
||||||
<div className="flex items-center gap-1 mb-1">
|
|
||||||
{getStatusIcon(status)}
|
|
||||||
<span className="font-semibold text-sm">{day}</span>
|
|
||||||
</div>
|
|
||||||
{hours > 0 && (
|
|
||||||
<span className="text-xs opacity-80">{hours}ч</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isToday && (
|
|
||||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Статистика за месяц */}
|
|
||||||
<div className="grid grid-cols-4 gap-3 mt-4">
|
|
||||||
<div className="text-center p-3 bg-white/10 rounded-lg">
|
|
||||||
<p className="text-emerald-200 font-semibold text-lg">{stats.workDays}</p>
|
|
||||||
<p className="text-white/60 text-xs">Рабочих дней</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/10 rounded-lg">
|
|
||||||
<p className="text-sky-200 font-semibold text-lg">{stats.vacationDays}</p>
|
|
||||||
<p className="text-white/60 text-xs">Отпуск</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/10 rounded-lg">
|
|
||||||
<p className="text-amber-200 font-semibold text-lg">{stats.sickDays}</p>
|
|
||||||
<p className="text-white/60 text-xs">Больничный</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-white/5 rounded-lg">
|
|
||||||
<p className="text-white font-semibold text-lg">{stats.totalHours}ч</p>
|
|
||||||
<p className="text-white/60 text-xs">Всего часов</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reports">
|
<TabsContent value="reports">
|
||||||
{employees.length === 0 ? (
|
<EmployeeReports
|
||||||
<Card className="glass-card p-6">
|
employees={employees}
|
||||||
<div className="flex items-center justify-center h-64">
|
onShowAddForm={() => setShowAddForm(true)}
|
||||||
<div className="text-center">
|
onExportCSV={exportToCSV}
|
||||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
onGenerateReport={generateReport}
|
||||||
<BarChart3 className="h-8 w-8 text-white/40" />
|
/>
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-white mb-2">Нет данных для отчетов</h3>
|
|
||||||
<p className="text-white/60 text-sm mb-4">
|
|
||||||
Добавьте сотрудников, чтобы генерировать отчеты и аналитику
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowAddForm(true)}
|
|
||||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Добавить сотрудника
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Статистические карточки */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<Card className="glass-card p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-white/70 text-sm">Всего сотрудников</p>
|
|
||||||
<p className="text-2xl font-bold text-white">{employees.length}</p>
|
|
||||||
</div>
|
|
||||||
<Users className="h-8 w-8 text-purple-400" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card className="glass-card p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-white/70 text-sm">Активных</p>
|
|
||||||
<p className="text-2xl font-bold text-green-400">
|
|
||||||
{employees.filter((e: Employee) => e.status === 'ACTIVE').length}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<BarChart3 className="h-8 w-8 text-green-400" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card className="glass-card p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-white/70 text-sm">Средняя зарплата</p>
|
|
||||||
<p className="text-2xl font-bold text-blue-400">
|
|
||||||
{employees.length > 0
|
|
||||||
? Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length).toLocaleString('ru-RU')
|
|
||||||
: '0'} ₽
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FileText className="h-8 w-8 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card className="glass-card p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-white/70 text-sm">Отделов</p>
|
|
||||||
<p className="text-2xl font-bold text-orange-400">
|
|
||||||
{new Set(employees.map((e: Employee) => e.position).filter(Boolean)).size}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Calendar className="h-8 w-8 text-orange-400" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Экспорт отчетов */}
|
|
||||||
<Card className="glass-card p-6">
|
|
||||||
<h3 className="text-white font-medium mb-4 flex items-center gap-2">
|
|
||||||
<Download className="h-5 w-5" />
|
|
||||||
Экспорт отчетов
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-white/80 font-medium">Детальный отчет (CSV)</h4>
|
|
||||||
<p className="text-white/60 text-sm">
|
|
||||||
Полная информация о всех сотрудниках в формате таблицы для Excel/Google Sheets
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={exportToCSV}
|
|
||||||
className="w-full glass-button"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Скачать CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-white/80 font-medium">Сводный отчет (TXT)</h4>
|
|
||||||
<p className="text-white/60 text-sm">
|
|
||||||
Краткая статистика и список сотрудников в текстовом формате
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={generateReport}
|
|
||||||
className="w-full glass-button"
|
|
||||||
>
|
|
||||||
<BarChart3 className="h-4 w-4 mr-2" />
|
|
||||||
Создать отчет
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Аналитика по отделам */}
|
|
||||||
<Card className="glass-card p-6">
|
|
||||||
<h3 className="text-white font-medium mb-4">Распределение по отделам</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from(new Set(employees.map((e: Employee) => e.position).filter(Boolean)) as Set<string>).map((position: string) => {
|
|
||||||
const positionEmployees = employees.filter((e: Employee) => e.position === position)
|
|
||||||
const percentage = Math.round((positionEmployees.length / employees.length) * 100)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={position} className="flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-white/80 text-sm">{position}</span>
|
|
||||||
<span className="text-white/60 text-xs">{positionEmployees.length} чел. ({percentage}%)</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-white/10 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full"
|
|
||||||
style={{ width: `${percentage}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
32
src/components/employees/month-navigation.tsx
Normal file
32
src/components/employees/month-navigation.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface MonthNavigationProps {
|
||||||
|
currentYear: number
|
||||||
|
currentMonth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthNavigation({ currentYear, currentMonth }: MonthNavigationProps) {
|
||||||
|
const monthName = new Date(currentYear, currentMonth).toLocaleDateString('ru-RU', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-white font-medium text-lg capitalize">{monthName}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
||||||
|
← Предыдущий
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
||||||
|
Сегодня
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
|
||||||
|
Следующий →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
Reference in New Issue
Block a user