Обновлен компонент панели управления сотрудниками: добавлены новые компоненты для заголовка, поиска, легенды статусов и состояния пустого списка. Оптимизирована логика отображения сотрудников и их табелей. Удалены неиспользуемые импорты и код для улучшения читаемости.
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 { 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 { 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 { 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 { 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 { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
||||
import {
|
||||
Users,
|
||||
Calendar,
|
||||
Search,
|
||||
Plus,
|
||||
FileText,
|
||||
Edit,
|
||||
UserX,
|
||||
Phone,
|
||||
Mail,
|
||||
Download,
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Plane,
|
||||
Activity,
|
||||
Clock,
|
||||
Briefcase,
|
||||
MapPin,
|
||||
AlertCircle,
|
||||
MessageCircle
|
||||
} from 'lucide-react'
|
||||
import { Users, FileText } from 'lucide-react'
|
||||
|
||||
// Интерфейс сотрудника
|
||||
interface Employee {
|
||||
@ -360,37 +341,16 @@ ${employees.map((emp: Employee) =>
|
||||
<main className="flex-1 ml-56 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Заголовок страницы */}
|
||||
<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={() => 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>
|
||||
<EmployeeHeader
|
||||
showAddForm={showAddForm}
|
||||
onToggleAddForm={() => setShowAddForm(!showAddForm)}
|
||||
/>
|
||||
|
||||
{/* Поиск */}
|
||||
<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) => setSearchQuery(e.target.value)}
|
||||
className="glass-input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<EmployeeSearch
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Форма добавления сотрудника */}
|
||||
{showAddForm && (
|
||||
@ -443,602 +403,51 @@ ${employees.map((emp: Employee) =>
|
||||
|
||||
if (filteredEmployees.length === 0) {
|
||||
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={() => 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>
|
||||
<EmployeeEmptyState
|
||||
searchQuery={searchQuery}
|
||||
onShowAddForm={() => setShowAddForm(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Навигация по месяцам */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white font-medium text-lg">Январь 2024</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>
|
||||
<MonthNavigation
|
||||
currentYear={currentYear}
|
||||
currentMonth={currentMonth}
|
||||
/>
|
||||
|
||||
{/* Легенда точно как в гите */}
|
||||
<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>
|
||||
{/* Легенда статусов */}
|
||||
<EmployeeLegend />
|
||||
|
||||
{/* Объединенный список сотрудников с табелем */}
|
||||
{filteredEmployees.map((employee: Employee) => {
|
||||
// Генерируем календарные дни для текущего месяца
|
||||
const currentDate = new Date()
|
||||
const currentMonth = currentDate.getMonth()
|
||||
const currentYear = currentDate.getFullYear()
|
||||
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 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>
|
||||
{filteredEmployees.map((employee: Employee) => (
|
||||
<EmployeeItem
|
||||
key={employee.id}
|
||||
employee={employee}
|
||||
employeeSchedules={employeeSchedules}
|
||||
currentYear={currentYear}
|
||||
currentMonth={currentMonth}
|
||||
onEdit={handleEditEmployee}
|
||||
onDelete={handleEmployeeDeleted}
|
||||
onDayStatusChange={changeDayStatus}
|
||||
deletingEmployeeId={deletingEmployeeId}
|
||||
/>
|
||||
))}
|
||||
</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 value="reports">
|
||||
{employees.length === 0 ? (
|
||||
<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={() => 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>
|
||||
)}
|
||||
<EmployeeReports
|
||||
employees={employees}
|
||||
onShowAddForm={() => setShowAddForm(true)}
|
||||
onExportCSV={exportToCSV}
|
||||
onGenerateReport={generateReport}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</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