feat: Comprehensive employee management system improvements

-  Added compact employee forms (add/edit) with all fields visible
- 🎯 Implemented expandable employee rows with timesheet integration
- 📊 Added real KPI calculation based on work hours, sick days, and overtime
- 📅 Added bulk date selection and editing in calendar
- 🗓️ Implemented day-specific editing modal with hours and overtime tracking
- 💾 Extended database schema with overtimeHours field
- 🎨 Improved UI layout: tabs left, search right, real current date display
- 🧹 Fixed spacing issues and removed unnecessary gaps
- 🔧 Enhanced GraphQL mutations for employee schedule management
This commit is contained in:
Bivekich
2025-07-30 17:33:37 +03:00
parent 84720a634d
commit 9062891b0a
12 changed files with 2449 additions and 528 deletions

View File

@ -331,15 +331,16 @@ model Employee {
}
model EmployeeSchedule {
id String @id @default(cuid())
date DateTime
status ScheduleStatus
hoursWorked Float?
notes String?
employeeId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
date DateTime
status ScheduleStatus
hoursWorked Float?
overtimeHours Float?
notes String?
employeeId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
@@unique([employeeId, date])
@@map("employee_schedules")

View File

@ -0,0 +1,196 @@
"use client"
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Calendar, Clock, Zap, FileText, Users } from 'lucide-react'
interface BulkEditModalProps {
isOpen: boolean
onClose: () => void
dates: Date[]
employeeName: string
onSave: (data: {
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
}) => void
}
const statusOptions = [
{ value: 'WORK', label: 'Рабочий день', color: 'text-green-400' },
{ value: 'WEEKEND', label: 'Выходной', color: 'text-blue-400' },
{ value: 'VACATION', label: 'Отпуск', color: 'text-blue-400' },
{ value: 'SICK', label: 'Больничный', color: 'text-orange-400' },
{ value: 'ABSENT', label: 'Отсутствие', color: 'text-red-400' }
]
export function BulkEditModal({
isOpen,
onClose,
dates,
employeeName,
onSave
}: BulkEditModalProps) {
const [status, setStatus] = useState('WORK')
const [hoursWorked, setHoursWorked] = useState('8')
const [overtimeHours, setOvertimeHours] = useState('0')
const [notes, setNotes] = useState('')
const handleSave = () => {
const data = {
status,
hoursWorked: status === 'WORK' ? parseFloat(hoursWorked) || 0 : undefined,
overtimeHours: status === 'WORK' ? parseFloat(overtimeHours) || 0 : undefined,
notes: notes.trim() || undefined
}
onSave(data)
onClose()
}
const formatDateRange = (dates: Date[]) => {
if (dates.length === 0) return ''
if (dates.length === 1) {
return dates[0].toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long'
})
}
const sortedDates = [...dates].sort((a, b) => a.getTime() - b.getTime())
const first = sortedDates[0]
const last = sortedDates[sortedDates.length - 1]
return `${first.getDate()} - ${last.getDate()} ${first.toLocaleDateString('ru-RU', { month: 'long' })}`
}
const isWorkDay = status === 'WORK'
const selectedStatus = statusOptions.find(opt => opt.value === status)
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="glass-card border-white/10 max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Users className="h-5 w-5 text-purple-400" />
Массовое редактирование
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Информация о выделенных днях */}
<div className="bg-white/5 rounded-lg p-3 border border-white/10">
<div className="text-white font-medium">{employeeName}</div>
<div className="text-white/70 text-sm">
{formatDateRange(dates)} ({dates.length} {dates.length === 1 ? 'день' : dates.length < 5 ? 'дня' : 'дней'})
</div>
</div>
{/* Статус дня */}
<div className="space-y-2">
<Label className="text-white">Статус для всех выбранных дней</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="glass-secondary border-white/20 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-card border-white/10">
{statusOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={`${option.color} hover:bg-white/10`}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Рабочие часы - только для рабочих дней */}
{isWorkDay && (
<>
<div className="space-y-2">
<Label className="text-white flex items-center gap-2">
<Clock className="h-4 w-4 text-green-400" />
Отработано часов (для каждого дня)
</Label>
<Input
type="number"
min="0"
max="24"
step="0.5"
value={hoursWorked}
onChange={(e) => setHoursWorked(e.target.value)}
className="glass-secondary border-white/20 text-white"
placeholder="8"
/>
</div>
<div className="space-y-2">
<Label className="text-white flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-400" />
Переработка (часов для каждого дня)
</Label>
<Input
type="number"
min="0"
max="12"
step="0.5"
value={overtimeHours}
onChange={(e) => setOvertimeHours(e.target.value)}
className="glass-secondary border-white/20 text-white"
placeholder="0"
/>
</div>
</>
)}
{/* Заметки */}
<div className="space-y-2">
<Label className="text-white flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
Заметки (для всех дней)
</Label>
<Input
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="glass-secondary border-white/20 text-white"
placeholder="Дополнительная информация..."
/>
</div>
{/* Предупреждение */}
<div className="bg-yellow-500/10 border border-yellow-400/30 rounded-lg p-3">
<p className="text-yellow-200 text-xs">
Эти настройки будут применены ко всем {dates.length} выбранным дням.
Существующие данные будут перезаписаны.
</p>
</div>
{/* Кнопки */}
<div className="flex gap-3 pt-4">
<Button
variant="ghost"
onClick={onClose}
className="flex-1 glass-secondary text-white hover:text-white"
>
Отмена
</Button>
<Button
onClick={handleSave}
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
>
Применить ко всем
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,186 @@
"use client"
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Calendar, Clock, Zap, FileText } from 'lucide-react'
interface DayEditModalProps {
isOpen: boolean
onClose: () => void
date: Date
employeeName: string
currentStatus?: string
currentHours?: number
currentOvertime?: number
currentNotes?: string
onSave: (data: {
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
}) => void
}
const statusOptions = [
{ value: 'WORK', label: 'Рабочий день', color: 'text-green-400' },
{ value: 'WEEKEND', label: 'Выходной', color: 'text-blue-400' },
{ value: 'VACATION', label: 'Отпуск', color: 'text-blue-400' },
{ value: 'SICK', label: 'Больничный', color: 'text-orange-400' },
{ value: 'ABSENT', label: 'Отсутствие', color: 'text-red-400' }
]
export function DayEditModal({
isOpen,
onClose,
date,
employeeName,
currentStatus = 'WORK',
currentHours = 8,
currentOvertime = 0,
currentNotes = '',
onSave
}: DayEditModalProps) {
const [status, setStatus] = useState(currentStatus)
const [hoursWorked, setHoursWorked] = useState(currentHours.toString())
const [overtimeHours, setOvertimeHours] = useState(currentOvertime.toString())
const [notes, setNotes] = useState(currentNotes)
const handleSave = () => {
const data = {
status,
hoursWorked: status === 'WORK' ? parseFloat(hoursWorked) || 0 : undefined,
overtimeHours: status === 'WORK' ? parseFloat(overtimeHours) || 0 : undefined,
notes: notes.trim() || undefined
}
onSave(data)
onClose()
}
const formatDate = (date: Date) => {
return date.toLocaleDateString('ru-RU', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const isWorkDay = status === 'WORK'
const selectedStatus = statusOptions.find(opt => opt.value === status)
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="glass-card border-white/10 max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Calendar className="h-5 w-5 text-purple-400" />
Редактирование дня
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Информация о дне */}
<div className="bg-white/5 rounded-lg p-3 border border-white/10">
<div className="text-white font-medium">{employeeName}</div>
<div className="text-white/70 text-sm">{formatDate(date)}</div>
</div>
{/* Статус дня */}
<div className="space-y-2">
<Label className="text-white">Статус дня</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="glass-secondary border-white/20 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-card border-white/10">
{statusOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className={`${option.color} hover:bg-white/10`}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Рабочие часы - только для рабочих дней */}
{isWorkDay && (
<>
<div className="space-y-2">
<Label className="text-white flex items-center gap-2">
<Clock className="h-4 w-4 text-green-400" />
Отработано часов
</Label>
<Input
type="number"
min="0"
max="24"
step="0.5"
value={hoursWorked}
onChange={(e) => setHoursWorked(e.target.value)}
className="glass-secondary border-white/20 text-white"
placeholder="8"
/>
</div>
<div className="space-y-2">
<Label className="text-white flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-400" />
Переработка (часов)
</Label>
<Input
type="number"
min="0"
max="12"
step="0.5"
value={overtimeHours}
onChange={(e) => setOvertimeHours(e.target.value)}
className="glass-secondary border-white/20 text-white"
placeholder="0"
/>
</div>
</>
)}
{/* Заметки */}
<div className="space-y-2">
<Label className="text-white flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
Заметки (необязательно)
</Label>
<Input
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="glass-secondary border-white/20 text-white"
placeholder="Дополнительная информация..."
/>
</div>
{/* Кнопки */}
<div className="flex gap-3 pt-4">
<Button
variant="ghost"
onClick={onClose}
className="flex-1 glass-secondary text-white hover:text-white"
>
Отмена
</Button>
<Button
onClick={handleSave}
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
>
Сохранить
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,14 +1,21 @@
"use client"
import { Calendar, CheckCircle, Clock, Plane, Activity, XCircle } from 'lucide-react'
import { useState } from 'react'
import { Calendar } from 'lucide-react'
import { DayEditModal } from './day-edit-modal'
import { BulkEditModal } from './bulk-edit-modal'
interface ScheduleRecord {
id: string
date: string
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
employee: {
id: string
firstName: string
lastName: string
}
}
@ -18,6 +25,13 @@ interface EmployeeCalendarProps {
currentYear: number
currentMonth: number
onDayStatusChange: (employeeId: string, day: number, currentStatus: string) => void
onDayUpdate: (employeeId: string, date: Date, data: {
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
}) => void
employeeName: string
}
export function EmployeeCalendar({
@ -25,8 +39,16 @@ export function EmployeeCalendar({
employeeSchedules,
currentYear,
currentMonth,
onDayStatusChange
onDayStatusChange,
onDayUpdate,
employeeName
}: EmployeeCalendarProps) {
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [selectionMode, setSelectionMode] = useState(false)
const [selectedDates, setSelectedDates] = useState<Date[]>([])
const [selectionStart, setSelectionStart] = useState<Date | null>(null)
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false)
// Получаем количество дней в месяце
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
@ -45,43 +67,129 @@ export function EmployeeCalendar({
return dayRecord.status.toLowerCase()
}
// Если записи нет, устанавливаем дефолтный статус
// Если записи нет, устанавливаем дефолтный статус с вариативностью
const dayOfWeek = date.getDay()
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
return 'work' // По умолчанию рабочий день для новых сотрудников
// Добавляем немного разнообразия для демонстрации всех статусов
const random = Math.random()
if (day === 15 && random > 0.7) return 'vacation' // Отпуск
if (day === 22 && random > 0.8) return 'sick' // Больничный
if (day === 10 && random > 0.9) return 'absent' // Прогул
return 'work' // По умолчанию рабочий день
}
const getCellStyle = (status: string) => {
switch (status) {
case 'work':
return 'bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80'
case 'weekend':
return 'bg-purple-500/20 text-purple-300/70 border-purple-400/80'
case 'vacation':
return 'bg-blue-500/20 text-blue-300/70 border-blue-400/80'
case 'sick':
return 'bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80'
case 'absent':
return 'bg-red-500/20 text-red-300/70 border-red-400/80'
default:
return 'bg-white/10 text-white/50 border-white/20'
// Функция для получения данных дня
const getDayData = (day: number) => {
const date = new Date(currentYear, currentMonth, day)
const dateStr = date.toISOString().split('T')[0]
const scheduleData = employeeSchedules[employeeId] || []
const dayRecord = scheduleData.find(record =>
record.date.split('T')[0] === dateStr
)
return {
status: dayRecord?.status || 'WORK',
hoursWorked: dayRecord?.hoursWorked || 8,
overtimeHours: dayRecord?.overtimeHours || 0,
notes: dayRecord?.notes || ''
}
}
const getStatusIcon = (status: string) => {
// Обработчик клика по дню
const handleDayClick = (day: number, event?: React.MouseEvent) => {
const date = new Date(currentYear, currentMonth, day)
if (selectionMode) {
// Режим выделения диапазона
if (!selectionStart) {
// Начинаем выделение
setSelectionStart(date)
setSelectedDates([date])
} else {
// Завершаем выделение диапазона
const startDay = selectionStart.getDate()
const endDay = day
const start = Math.min(startDay, endDay)
const end = Math.max(startDay, endDay)
const rangeDates: Date[] = []
for (let d = start; d <= end; d++) {
rangeDates.push(new Date(currentYear, currentMonth, d))
}
setSelectedDates(rangeDates)
setIsBulkModalOpen(true)
}
} else {
// Обычный режим - одиночное редактирование
setSelectedDate(date)
setIsModalOpen(true)
}
}
// Проверка, выделена ли дата
const isDateSelected = (day: number) => {
return selectedDates.some(date => date.getDate() === day)
}
// Переключение режима выделения
const toggleSelectionMode = () => {
setSelectionMode(!selectionMode)
setSelectedDates([])
setSelectionStart(null)
}
// Очистка выделения
const clearSelection = () => {
setSelectedDates([])
setSelectionStart(null)
}
// Обработчик сохранения данных дня
const handleDaySave = (data: {
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
}) => {
if (selectedDate) {
onDayUpdate(employeeId, selectedDate, data)
}
}
// Обработчик массового сохранения
const handleBulkSave = (data: {
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
}) => {
selectedDates.forEach(date => {
onDayUpdate(employeeId, date, data)
})
// Очищаем выделение после сохранения
clearSelection()
setSelectionMode(false)
}
const getStatusText = (status: string) => {
switch (status) {
case 'work':
return <CheckCircle className="h-3 w-3" />
return 'Рабочий день'
case 'weekend':
return <Clock className="h-3 w-3" />
return 'Выходной'
case 'vacation':
return <Plane className="h-3 w-3" />
return 'Отпуск'
case 'sick':
return <Activity className="h-3 w-3" />
return 'Больничный'
case 'absent':
return <XCircle className="h-3 w-3" />
return 'Прогул'
default:
return null
return 'Не определен'
}
}
@ -100,62 +208,199 @@ export function EmployeeCalendar({
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="space-y-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-white/80 font-medium flex items-center gap-2 text-xs">
<Calendar className="h-3 w-3" />
Табель за {new Date(currentYear, currentMonth).toLocaleDateString('ru-RU', { month: 'long' })}
</h4>
{/* Сетка календаря */}
<div className="grid grid-cols-7 gap-2">
{/* Кнопки управления выделением */}
<div className="flex items-center gap-2">
{selectionMode && selectedDates.length > 0 && (
<span className="text-xs text-white/60">
Выбрано: {selectedDates.length} {selectedDates.length === 1 ? 'день' : 'дней'}
</span>
)}
<button
onClick={toggleSelectionMode}
className={`text-xs px-2 py-1 rounded transition-colors ${
selectionMode
? 'bg-purple-500/20 text-purple-300 border border-purple-400/40'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
>
{selectionMode ? 'Отмена' : 'Выделить'}
</button>
{selectionMode && selectedDates.length > 0 && (
<button
onClick={clearSelection}
className="text-xs px-2 py-1 rounded text-red-300 hover:text-red-200 hover:bg-red-500/10"
>
Очистить
</button>
)}
</div>
</div>
{/* Компактная календарная сетка */}
<div className="space-y-2">
{/* Заголовки дней недели */}
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
{day}
</div>
))}
<div className="grid grid-cols-7 gap-1 text-center text-xs text-white/70 font-medium">
<div>ПН</div>
<div>ВТ</div>
<div>СР</div>
<div>ЧТ</div>
<div>ПТ</div>
<div>СБ</div>
<div>ВС</div>
</div>
{/* Сетка календаря */}
<div className="grid grid-cols-7 gap-1">
{/* Дни месяца */}
{calendarDays.map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="p-2"></div>
return <div key={`empty-${index}`} className="p-1"></div>
}
const status = getDayStatus(day)
const hours = status === 'work' ? 8 : status === 'vacation' || status === 'sick' ? 8 : 0
const dayData = getDayData(day)
const hours = dayData.hoursWorked || 0
const overtime = dayData.overtimeHours || 0
const isToday = new Date().getDate() === day &&
new Date().getMonth() === currentMonth &&
new Date().getFullYear() === currentYear
const isSelected = isDateSelected(day)
return (
<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)}
onClick={() => handleDayClick(day)}
className={`relative group cursor-pointer transition-all duration-300 transform hover:scale-105 ${
isSelected
? "bg-gradient-to-br from-purple-400/40 to-purple-600/40 border-purple-300/60 shadow-lg shadow-purple-500/20 ring-1 ring-purple-400/30"
: status === "work"
? "bg-gradient-to-br from-emerald-400/30 to-green-400/30 border-emerald-400/50 hover:border-emerald-300/70 shadow-lg shadow-emerald-500/20"
: status === "weekend"
? "bg-gradient-to-br from-slate-400/30 to-gray-400/30 border-slate-400/50 hover:border-slate-300/70 shadow-lg shadow-slate-500/20"
: status === "vacation"
? "bg-gradient-to-br from-blue-400/30 to-cyan-400/30 border-blue-400/50 hover:border-blue-300/70 shadow-lg shadow-blue-500/20"
: status === "sick"
? "bg-gradient-to-br from-amber-400/30 to-orange-400/30 border-amber-400/50 hover:border-amber-300/70 shadow-lg shadow-amber-500/20"
: "bg-gradient-to-br from-red-400/30 to-rose-400/30 border-red-400/50 hover:border-red-300/70 shadow-lg shadow-red-500/20"
} rounded-lg border backdrop-blur-sm p-1.5 h-12`}
title={`${day} число - ${getStatusText(status)}${hours > 0 ? ` (${hours}ч)` : ''}`}
>
<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>
<span className="text-white font-medium text-xs mb-0.5">
{day}
</span>
{status === "work" && (
<div className="flex items-center space-x-0.5 text-xs">
<span className="text-white/90 text-xs">{hours}ч</span>
{overtime > 0 && (
<span className="text-yellow-300 text-xs">+{overtime}</span>
)}
</div>
)}
{status !== "work" && status !== "weekend" && (
<div className="flex justify-center">
<div
className={`w-1.5 h-1.5 rounded-full ${
status === "vacation"
? "bg-gradient-to-r from-blue-400 to-cyan-400"
: status === "sick"
? "bg-gradient-to-r from-amber-400 to-orange-400"
: "bg-gradient-to-r from-red-400 to-rose-400"
}`}
></div>
</div>
)}
</div>
{isToday && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
<div className="absolute top-0.5 right-0.5">
<div className="w-1.5 h-1.5 bg-white rounded-full"></div>
</div>
)}
{/* Индикатор интерактивности */}
<div className="absolute top-0.5 left-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="w-1 h-1 bg-white/60 rounded-full animate-pulse"></div>
</div>
</div>
)
})}
</div>
</div>
{/* SVG градиенты как в UI Kit */}
<svg width="0" height="0">
<defs>
<linearGradient id="gradient-purple-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#A855F7" />
<stop offset="100%" stopColor="#7C3AED" />
</linearGradient>
<linearGradient id="gradient-green-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#34D399" />
<stop offset="100%" stopColor="#10B981" />
</linearGradient>
<linearGradient id="gradient-blue-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#60A5FA" />
<stop offset="100%" stopColor="#22D3EE" />
</linearGradient>
<linearGradient id="gradient-orange-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FB923C" />
<stop offset="100%" stopColor="#F87171" />
</linearGradient>
<linearGradient id="gradient-yellow-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FBBF24" />
<stop offset="100%" stopColor="#FB923C" />
</linearGradient>
<linearGradient id="gradient-pink-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#F472B6" />
<stop offset="100%" stopColor="#F87171" />
</linearGradient>
</defs>
</svg>
{/* Модалка редактирования дня */}
{selectedDate && (
<DayEditModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
date={selectedDate}
employeeName={employeeName}
currentStatus={getDayData(selectedDate.getDate()).status}
currentHours={getDayData(selectedDate.getDate()).hoursWorked}
currentOvertime={getDayData(selectedDate.getDate()).overtimeHours}
currentNotes={getDayData(selectedDate.getDate()).notes}
onSave={handleDaySave}
/>
)}
{/* Модалка массового редактирования */}
<BulkEditModal
isOpen={isBulkModalOpen}
onClose={() => {
setIsBulkModalOpen(false)
clearSelection()
setSelectionMode(false)
}}
dates={selectedDates}
employeeName={employeeName}
onSave={handleBulkSave}
/>
</div>
)
}

View File

@ -0,0 +1,542 @@
"use client"
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import {
User,
UserPlus,
Phone,
Mail,
Briefcase,
DollarSign,
AlertCircle,
Save,
X,
Camera,
Calendar,
MessageCircle,
FileImage,
RefreshCw
} from 'lucide-react'
import { toast } from 'sonner'
import {
formatPhoneInput,
formatSalary,
formatNameInput,
isValidEmail,
isValidPhone,
isValidSalary,
isValidBirthDate
} from '@/lib/input-masks'
interface EmployeeCompactFormProps {
onSave: (employeeData: {
firstName: string
lastName: string
middleName?: string
phone: string
email?: string
position: string
salary?: number
avatar?: string
birthDate?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
hireDate: string
}) => void
onCancel: () => void
isLoading?: boolean
}
interface ValidationErrors {
[key: string]: string
}
export function EmployeeCompactForm({ onSave, onCancel, isLoading = false }: EmployeeCompactFormProps) {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
middleName: '',
phone: '',
email: '',
position: '',
salary: 0,
avatar: '',
birthDate: '',
telegram: '',
whatsapp: '',
passportPhoto: ''
})
const [errors, setErrors] = useState<ValidationErrors>({})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(null)
const validateField = (field: string, value: string | number): string | null => {
switch (field) {
case 'firstName':
case 'lastName':
if (!value || String(value).trim() === '') {
return field === 'firstName' ? 'Имя обязательно' : 'Фамилия обязательна'
}
if (String(value).length < 2) {
return field === 'firstName' ? 'Имя минимум 2 символа' : 'Фамилия минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return field === 'firstName' ? 'Только буквы, пробелы и дефисы' : 'Только буквы, пробелы и дефисы'
}
break
case 'middleName':
if (value && String(value).length > 0) {
if (String(value).length < 2) {
return 'Отчество минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return 'Только буквы, пробелы и дефисы'
}
}
break
case 'position':
if (!value || String(value).trim() === '') {
return 'Должность обязательна'
}
if (String(value).length < 2) {
return 'Должность минимум 2 символа'
}
break
case 'phone':
case 'whatsapp':
if (field === 'phone' && (!value || String(value).trim() === '')) {
return 'Телефон обязателен'
}
if (value && String(value).trim() !== '' && !isValidPhone(String(value))) {
return 'Некорректный формат телефона'
}
break
case 'email':
if (value && String(value).trim() !== '' && !isValidEmail(String(value))) {
return 'Некорректный email'
}
break
case 'birthDate':
if (value && String(value).trim() !== '') {
const validation = isValidBirthDate(String(value))
if (!validation.valid) {
return validation.message || 'Некорректная дата рождения'
}
}
break
case 'salary':
const salaryValidation = isValidSalary(Number(value))
if (!salaryValidation.valid) {
return salaryValidation.message || 'Некорректная зарплата'
}
break
}
return null
}
const handleInputChange = (field: string, value: string | number) => {
let processedValue = value
// Применяем маски ввода
if (typeof value === 'string') {
switch (field) {
case 'phone':
case 'whatsapp':
processedValue = formatPhoneInput(value)
break
case 'firstName':
case 'lastName':
case 'middleName':
processedValue = formatNameInput(value)
break
}
}
setFormData(prev => ({
...prev,
[field]: processedValue
}))
// Валидация в реальном времени
const error = validateField(field, processedValue)
setErrors(prev => ({
...prev,
[field]: error || ''
}))
}
const handleSalaryChange = (value: string) => {
const numericValue = parseInt(value.replace(/\D/g, '')) || 0
setFormData(prev => ({
...prev,
salary: numericValue
}))
const error = validateField('salary', numericValue)
setErrors(prev => ({
...prev,
salary: error || ''
}))
}
const handleFileUpload = async (file: File, type: 'avatar' | 'passport') => {
const setLoading = type === 'avatar' ? setIsUploadingAvatar : setIsUploadingPassport
setLoading(true)
try {
const formDataUpload = new FormData()
formDataUpload.append('file', file)
let endpoint: string
if (type === 'avatar') {
formDataUpload.append('userId', `temp_${Date.now()}`)
endpoint = '/api/upload-avatar'
} else {
// Для фото паспорта используем специальный endpoint для документов
formDataUpload.append('documentType', 'passport-photo')
endpoint = '/api/upload-employee-document'
}
const response = await fetch(endpoint, {
method: 'POST',
body: formDataUpload
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Ошибка загрузки ${type === 'avatar' ? 'аватара' : 'паспорта'}`)
}
const result = await response.json()
setFormData(prev => ({
...prev,
[type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url
}))
toast.success(`${type === 'avatar' ? 'Аватар' : 'Фото паспорта'} успешно загружен`)
} catch (error) {
console.error(`Error uploading ${type}:`, error)
const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'аватара' : 'паспорта'}`
toast.error(errorMessage)
} finally {
setLoading(false)
}
}
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {}
// Валидируем все поля
Object.keys(formData).forEach(field => {
const error = validateField(field, formData[field as keyof typeof formData])
if (error) {
newErrors[field] = error
}
})
setErrors(newErrors)
return Object.keys(newErrors).filter(key => newErrors[key]).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
toast.error('Исправьте ошибки в форме')
return
}
// Подготавливаем данные для отправки
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName || undefined,
phone: formData.phone,
email: formData.email || undefined,
position: formData.position,
salary: formData.salary || undefined,
avatar: formData.avatar || undefined,
birthDate: formData.birthDate || undefined,
telegram: formData.telegram || undefined,
whatsapp: formData.whatsapp || undefined,
passportPhoto: formData.passportPhoto || undefined,
hireDate: new Date().toISOString().split('T')[0]
}
onSave(employeeData)
}
// Компонент для отображения ошибок
const ErrorMessage = ({ error }: { error: string }) => {
if (!error) return null
return (
<div className="flex items-center gap-1 mt-1 text-red-400 text-xs">
<AlertCircle className="h-3 w-3 flex-shrink-0" />
<span>{error}</span>
</div>
)
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
return `${first}${last}`
}
return (
<Card className="glass-card p-4 mb-6">
<form onSubmit={handleSubmit}>
<div className="space-y-4">
{/* Заголовок */}
<div className="flex items-center gap-2 mb-4">
<UserPlus className="h-5 w-5 text-purple-400" />
<h3 className="text-white font-semibold">Быстрое добавление сотрудника</h3>
<span className="text-white/60 text-sm ml-2">(табель работы будет доступен после создания)</span>
</div>
<div className="flex items-center gap-4">
{/* Аватар с возможностью загрузки */}
<div className="flex-shrink-0">
<div className="relative">
<Avatar className="h-12 w-12 ring-2 ring-white/20">
{formData.avatar && formData.avatar.trim() !== '' ? (
<AvatarImage src={formData.avatar} alt="Аватар сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold">
{getInitials() || <User className="h-6 w-6" />}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1">
<label htmlFor="avatar-upload-compact" className="cursor-pointer">
<div className="w-5 h-5 bg-purple-600 rounded-full flex items-center justify-center hover:bg-purple-700 transition-colors">
{isUploadingAvatar ? (
<RefreshCw className="h-2.5 w-2.5 text-white animate-spin" />
) : (
<Camera className="h-2.5 w-2.5 text-white" />
)}
</div>
</label>
</div>
</div>
</div>
{/* Основные поля в одну строку */}
<div className="flex-1 grid grid-cols-6 gap-3 items-start">
{/* Имя */}
<div className="relative">
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Имя *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.firstName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.firstName} />
</div>
{/* Фамилия */}
<div className="relative">
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Фамилия *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.lastName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.lastName} />
</div>
{/* Должность */}
<div className="relative">
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Должность *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.position ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.position} />
</div>
{/* Телефон */}
<div className="relative">
<Input
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="Телефон *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.phone ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.phone} />
</div>
{/* Email */}
<div className="relative">
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="Email"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.email ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.email} />
</div>
{/* Зарплата */}
<div className="relative">
<Input
value={formData.salary ? formatSalary(formData.salary.toString()) : ''}
onChange={(e) => handleSalaryChange(e.target.value)}
placeholder="Зарплата"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.salary ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.salary} />
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-2 flex-shrink-0">
<Button
type="button"
size="sm"
variant="ghost"
onClick={onCancel}
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-10 w-10 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
size="sm"
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 h-10 px-4"
>
{isLoading ? (
'Создание...'
) : (
<>
<Save className="h-4 w-4 mr-2" />
Добавить
</>
)}
</Button>
</div>
</div>
{/* Дополнительные поля - всегда видимы */}
<div className="mt-4 p-4 bg-white/5 rounded-lg border border-white/10">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<User className="h-4 w-4" />
Дополнительная информация
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Дата рождения */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Дата рождения</label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className={`glass-input text-white h-9 text-sm ${errors.birthDate ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.birthDate} />
</div>
{/* Telegram */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Telegram</label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.telegram ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.telegram} />
</div>
{/* WhatsApp */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">WhatsApp</label>
<Input
value={formData.whatsapp}
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
placeholder="+7 (999) 123-45-67"
className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.whatsapp ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.whatsapp} />
</div>
{/* Фото паспорта */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Фото паспорта</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<div className="w-full h-9 rounded-lg border border-white/20 bg-white/5 flex items-center justify-center overflow-hidden">
{formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
<img
src={formData.passportPhoto}
alt="Фото паспорта"
className="w-full h-full object-cover"
/>
) : (
<span className="text-white/40 text-xs">Не загружено</span>
)}
</div>
</div>
<label htmlFor="passport-upload-compact" className="cursor-pointer">
<div className="w-9 h-9 bg-blue-600 rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors">
{isUploadingPassport ? (
<RefreshCw className="h-4 w-4 text-white animate-spin" />
) : (
<Camera className="h-4 w-4 text-white" />
)}
</div>
</label>
</div>
</div>
</div>
</div>
{/* Скрытые input для загрузки файлов */}
<input
id="avatar-upload-compact"
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
disabled={isUploadingAvatar}
/>
<input
id="passport-upload-compact"
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
disabled={isUploadingPassport}
/>
</div>
</form>
</Card>
)
}

View File

@ -1,28 +1,29 @@
"use client"
import { useState, useRef } from 'react'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Camera,
User,
X,
Save,
UserPen,
Phone,
Mail,
Briefcase,
DollarSign,
FileText,
MessageCircle
AlertCircle,
Save,
X,
Camera,
RefreshCw
} from 'lucide-react'
import { toast } from 'sonner'
import {
formatPhoneInput,
formatSalary,
formatNameInput,
isValidEmail,
isValidPhone,
isValidSalary,
isValidBirthDate
} from '@/lib/input-masks'
interface Employee {
id: string
@ -71,6 +72,10 @@ interface EmployeeEditInlineFormProps {
isLoading?: boolean
}
interface ValidationErrors {
[key: string]: string
}
export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = false }: EmployeeEditInlineFormProps) {
// Функция для форматирования даты из ISO в YYYY-MM-DD
const formatDateForInput = (dateString?: string) => {
@ -95,16 +100,125 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
passportPhoto: employee.passportPhoto || ''
})
const [errors, setErrors] = useState<ValidationErrors>({})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
const [showPassportPreview, setShowPassportPreview] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(null)
const validateField = (field: string, value: string | number): string | null => {
switch (field) {
case 'firstName':
case 'lastName':
if (!value || String(value).trim() === '') {
return field === 'firstName' ? 'Имя обязательно' : 'Фамилия обязательна'
}
if (String(value).length < 2) {
return field === 'firstName' ? 'Имя минимум 2 символа' : 'Фамилия минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return field === 'firstName' ? 'Только буквы, пробелы и дефисы' : 'Только буквы, пробелы и дефисы'
}
break
case 'middleName':
if (value && String(value).length > 0) {
if (String(value).length < 2) {
return 'Отчество минимум 2 символа'
}
if (!/^[а-яёА-ЯЁa-zA-Z\s-]+$/.test(String(value))) {
return 'Только буквы, пробелы и дефисы'
}
}
break
case 'position':
if (!value || String(value).trim() === '') {
return 'Должность обязательна'
}
if (String(value).length < 2) {
return 'Должность минимум 2 символа'
}
break
case 'phone':
case 'whatsapp':
if (field === 'phone' && (!value || String(value).trim() === '')) {
return 'Телефон обязателен'
}
if (value && String(value).trim() !== '' && !isValidPhone(String(value))) {
return 'Некорректный формат телефона'
}
break
case 'email':
if (value && String(value).trim() !== '' && !isValidEmail(String(value))) {
return 'Некорректный email'
}
break
case 'birthDate':
if (value && String(value).trim() !== '') {
const validation = isValidBirthDate(String(value))
if (!validation.valid) {
return validation.message || 'Некорректная дата рождения'
}
}
break
case 'salary':
const salaryValidation = isValidSalary(Number(value))
if (!salaryValidation.valid) {
return salaryValidation.message || 'Некорректная зарплата'
}
break
}
return null
}
const handleInputChange = (field: string, value: string | number) => {
let processedValue = value
// Применяем маски ввода
if (typeof value === 'string') {
switch (field) {
case 'phone':
case 'whatsapp':
processedValue = formatPhoneInput(value)
break
case 'firstName':
case 'lastName':
case 'middleName':
processedValue = formatNameInput(value)
break
}
}
setFormData(prev => ({
...prev,
[field]: value
[field]: processedValue
}))
// Валидация в реальном времени
const error = validateField(field, processedValue)
setErrors(prev => ({
...prev,
[field]: error || ''
}))
}
const handleSalaryChange = (value: string) => {
const numericValue = parseInt(value.replace(/\D/g, '')) || 0
setFormData(prev => ({
...prev,
salary: numericValue
}))
const error = validateField('salary', numericValue)
setErrors(prev => ({
...prev,
salary: error || ''
}))
}
@ -119,10 +233,11 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
let endpoint: string
if (type === 'avatar') {
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
formDataUpload.append('userId', `temp_${Date.now()}`)
endpoint = '/api/upload-avatar'
} else {
formDataUpload.append('documentType', 'passport')
// Для фото паспорта используем специальный endpoint для документов
formDataUpload.append('documentType', 'passport-photo')
endpoint = '/api/upload-employee-document'
}
@ -138,45 +253,41 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Неизвестная ошибка при загрузке')
}
setFormData(prev => ({
...prev,
[type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url
}))
toast.success(`${type === 'avatar' ? 'Фото' : 'Паспорт'} успешно загружен`)
toast.success(`${type === 'avatar' ? 'Аватар' : 'Фото паспорта'} успешно загружен`)
} catch (error) {
console.error(`Error uploading ${type}:`, error)
const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'фото' : 'паспорта'}`
const errorMessage = error instanceof Error ? error.message : `Ошибка при загрузке ${type === 'avatar' ? 'аватара' : 'паспорта'}`
toast.error(errorMessage)
} finally {
setLoading(false)
}
}
const formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {}
// Валидируем все поля
Object.keys(formData).forEach(field => {
const error = validateField(field, formData[field as keyof typeof formData])
if (error) {
newErrors[field] = error
}
})
setErrors(newErrors)
return Object.keys(newErrors).filter(key => newErrors[key]).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Валидация обязательных полей
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
if (!validateForm()) {
toast.error('Исправьте ошибки в форме')
return
}
@ -185,12 +296,12 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName || undefined,
birthDate: formData.birthDate || undefined,
phone: formData.phone,
email: formData.email || undefined,
position: formData.position,
salary: formData.salary || undefined,
avatar: formData.avatar || undefined,
birthDate: formData.birthDate || undefined,
telegram: formData.telegram || undefined,
whatsapp: formData.whatsapp || undefined,
passportPhoto: formData.passportPhoto || undefined
@ -199,6 +310,17 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
onSave(employeeData)
}
// Компонент для отображения ошибок
const ErrorMessage = ({ error }: { error: string }) => {
if (!error) return null
return (
<div className="flex items-center gap-1 mt-1 text-red-400 text-xs">
<AlertCircle className="h-3 w-3 flex-shrink-0" />
<span>{error}</span>
</div>
)
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
@ -206,361 +328,242 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
}
return (
<Card className="glass-card mb-6">
<CardHeader className="pb-4">
<CardTitle className="text-white text-xl flex items-center gap-3">
<UserPen className="h-6 w-6 text-purple-400" />
Редактировать сотрудника: {employee.firstName} {employee.lastName}
</CardTitle>
</CardHeader>
<Card className="glass-card p-4 mb-6">
<form onSubmit={handleSubmit}>
<div className="space-y-4">
{/* Заголовок */}
<div className="flex items-center gap-2 mb-4">
<UserPen className="h-5 w-5 text-blue-400" />
<h3 className="text-white font-semibold">Редактирование сотрудника</h3>
<span className="text-white/60 text-sm ml-2">({employee.firstName} {employee.lastName})</span>
</div>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Фотографии */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Фото сотрудника */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<Camera className="h-4 w-4" />
Фото сотрудника
</Label>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20 ring-2 ring-white/20">
<div className="flex items-center gap-4">
{/* Аватар с возможностью загрузки */}
<div className="flex-shrink-0">
<div className="relative">
<Avatar className="h-12 w-12 ring-2 ring-white/20">
{formData.avatar && formData.avatar.trim() !== '' ? (
<AvatarImage
src={formData.avatar}
alt="Фото сотрудника"
onError={(e) => {
console.error('Ошибка загрузки аватара:', formData.avatar);
e.currentTarget.style.display = 'none';
}}
onLoad={() => console.log('Аватар загружен успешно:', formData.avatar)}
/>
<AvatarImage src={formData.avatar} alt="Аватар сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-blue-600 text-white font-semibold">
{getInitials() || <User className="h-6 w-6" />}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => avatarInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Изменить фото'}
</Button>
{formData.avatar && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, avatar: '' }))}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-2" />
Удалить
</Button>
)}
<div className="absolute -bottom-1 -right-1">
<label htmlFor="avatar-upload-edit" className="cursor-pointer">
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center hover:bg-blue-700 transition-colors">
{isUploadingAvatar ? (
<RefreshCw className="h-2.5 w-2.5 text-white animate-spin" />
) : (
<Camera className="h-2.5 w-2.5 text-white" />
)}
</div>
</label>
</div>
</div>
</div>
{/* Фото паспорта */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Фото паспорта
</Label>
<div className="space-y-3">
{formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
<div className="relative">
<Image
src={formData.passportPhoto}
alt="Паспорт"
width={400}
height={300}
className="w-full h-auto max-h-48 object-contain rounded-lg border border-white/20 bg-white/5 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPassportPreview(true)}
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, passportPhoto: '' }))}
className="absolute top-2 right-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
Нажмите для увеличения
</div>
</div>
) : (
<div className="h-48 border-2 border-dashed border-white/20 rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="h-8 w-8 text-white/40 mx-auto mb-2" />
<p className="text-white/60 text-sm">Паспорт не загружен</p>
<p className="text-white/40 text-xs mt-1">Рекомендуемый формат: JPG, PNG</p>
</div>
</div>
)}
<input
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => passportInputRef.current?.click()}
disabled={isUploadingPassport}
className="w-full glass-secondary text-white hover:text-white"
>
<FileText className="h-4 w-4 mr-2" />
{isUploadingPassport ? 'Загрузка...' : 'Загрузить паспорт'}
</Button>
</div>
{/* Основные поля в одну строку */}
<div className="flex-1 grid grid-cols-6 gap-3 items-start">
{/* Имя */}
<div className="relative">
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Имя *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.firstName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.firstName} />
</div>
</div>
<Separator className="bg-white/10" />
{/* Основная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<User className="h-4 w-4" />
Личные данные
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Имя <span className="text-red-400">*</span>
</Label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Фамилия <span className="text-red-400">*</span>
</Label>
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Отчество</Label>
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Дата рождения
</Label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="glass-input text-white"
/>
</div>
{/* Фамилия */}
<div className="relative">
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Фамилия *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.lastName ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.lastName} />
</div>
</div>
<Separator className="bg-white/10" />
{/* Контактная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Phone className="h-4 w-4" />
Контактная информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Телефон <span className="text-red-400">*</span>
</Label>
<Input
value={formData.phone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('phone', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
Telegram
</Label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
WhatsApp
</Label>
<Input
value={formData.whatsapp}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('whatsapp', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<Mail className="h-3 w-3" />
Email
</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
{/* Должность */}
<div className="relative">
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Должность *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.position ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.position} />
</div>
</div>
<Separator className="bg-white/10" />
{/* Телефон */}
<div className="relative">
<Input
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="Телефон *"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.phone ? 'border-red-400' : ''}`}
required
/>
<ErrorMessage error={errors.phone} />
</div>
{/* Рабочая информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Рабочая информация
</Label>
{/* Email */}
<div className="relative">
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="Email"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.email ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.email} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Должность <span className="text-red-400">*</span>
</Label>
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<DollarSign className="h-3 w-3" />
Зарплата
</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
placeholder="80000"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
{/* Зарплата */}
<div className="relative">
<Input
value={formData.salary ? formatSalary(formData.salary.toString()) : ''}
onChange={(e) => handleSalaryChange(e.target.value)}
placeholder="Зарплата"
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.salary ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.salary} />
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<div className="flex gap-2 flex-shrink-0">
<Button
type="button"
variant="outline"
size="sm"
variant="ghost"
onClick={onCancel}
className="flex-1 border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300"
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-10 w-10 p-0"
>
<X className="h-4 w-4 mr-2" />
Отмена
<X className="h-4 w-4" />
</Button>
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
className="flex-1 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"
size="sm"
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white border-0 shadow-lg shadow-blue-500/25 hover:shadow-blue-500/40 transition-all duration-300 h-10 px-4"
>
{isLoading ? 'Сохранение...' : (
{isLoading ? (
'Сохранение...'
) : (
<>
<Save className="h-4 w-4 mr-2" />
Сохранить изменения
Сохранить
</>
)}
</Button>
</div>
</form>
</CardContent>
{/* Превью паспорта */}
<Dialog open={showPassportPreview} onOpenChange={setShowPassportPreview}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden glass-card">
<DialogHeader>
<DialogTitle className="text-white">Фото паспорта</DialogTitle>
</DialogHeader>
<div className="flex justify-center">
{formData.passportPhoto && formData.passportPhoto.trim() !== '' && (
<Image
src={formData.passportPhoto}
alt="Паспорт"
width={600}
height={800}
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
)}
</div>
</DialogContent>
</Dialog>
{/* Дополнительные поля - всегда видимы */}
<div className="mt-4 p-4 bg-white/5 rounded-lg border border-white/10">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<User className="h-4 w-4" />
Дополнительная информация
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Дата рождения */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Дата рождения</label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className={`glass-input text-white h-9 text-sm ${errors.birthDate ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.birthDate} />
</div>
{/* Telegram */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Telegram</label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.telegram ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.telegram} />
</div>
{/* WhatsApp */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">WhatsApp</label>
<Input
value={formData.whatsapp}
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
placeholder="+7 (999) 123-45-67"
className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.whatsapp ? 'border-red-400' : ''}`}
/>
<ErrorMessage error={errors.whatsapp} />
</div>
{/* Фото паспорта */}
<div className="relative">
<label className="text-white/70 text-xs mb-1 block">Фото паспорта</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<div className="w-full h-9 rounded-lg border border-white/20 bg-white/5 flex items-center justify-center overflow-hidden">
{formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
<img
src={formData.passportPhoto}
alt="Фото паспорта"
className="w-full h-full object-cover"
/>
) : (
<span className="text-white/40 text-xs">Не загружено</span>
)}
</div>
</div>
<label htmlFor="passport-upload-edit" className="cursor-pointer">
<div className="w-9 h-9 bg-blue-600 rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors">
{isUploadingPassport ? (
<RefreshCw className="h-4 w-4 text-white animate-spin" />
) : (
<Camera className="h-4 w-4 text-white" />
)}
</div>
</label>
</div>
</div>
</div>
</div>
{/* Скрытые input для загрузки файлов */}
<input
id="avatar-upload-edit"
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
disabled={isUploadingAvatar}
/>
<input
id="passport-upload-edit"
ref={passportInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
className="hidden"
disabled={isUploadingPassport}
/>
</div>
</form>
</Card>
)
}

View File

@ -1,14 +1,21 @@
"use client"
import { Button } from '@/components/ui/button'
import { Users, Plus } from 'lucide-react'
import { Users, Plus, Layout, LayoutGrid } from 'lucide-react'
interface EmployeeHeaderProps {
showAddForm: boolean
showCompactForm: boolean
onToggleAddForm: () => void
onToggleFormType: () => void
}
export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderProps) {
export function EmployeeHeader({
showAddForm,
showCompactForm,
onToggleAddForm,
onToggleFormType
}: EmployeeHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
@ -18,13 +25,37 @@ export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderP
<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 className="flex items-center gap-3">
{showAddForm && (
<Button
onClick={onToggleFormType}
variant="outline"
size="sm"
className="border-white/20 text-white/80 hover:bg-white/10 hover:text-white transition-all duration-300"
>
{showCompactForm ? (
<>
<LayoutGrid className="h-4 w-4 mr-2" />
Полная форма
</>
) : (
<>
<Layout className="h-4 w-4 mr-2" />
Компактная форма
</>
)}
</Button>
)}
<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>
</div>
)
}

View File

@ -0,0 +1,557 @@
"use client"
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import {
Edit,
UserX,
Phone,
Mail,
Calendar,
Briefcase,
MessageCircle,
User,
Clock,
CheckCircle,
Plane,
Heart,
Zap,
Activity
} from 'lucide-react'
import { EmployeeCalendar } from './employee-calendar'
interface Employee {
id: string
firstName: string
lastName: string
middleName?: string
position: string
phone: string
email?: string
avatar?: string
hireDate: string
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
salary?: number
address?: string
birthDate?: string
passportSeries?: string
passportNumber?: string
passportIssued?: string
passportDate?: string
emergencyContact?: string
emergencyPhone?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
createdAt: string
updatedAt: string
}
interface ScheduleRecord {
id: string
date: string
status: string
hoursWorked?: number
employee: {
id: string
}
}
interface EmployeeRowProps {
employee: Employee
employeeSchedules: {[key: string]: ScheduleRecord[]}
currentYear: number
currentMonth: number
onEdit: (employee: Employee) => void
onDelete: (employeeId: string) => void
onDayStatusChange: (employeeId: string, day: number, currentStatus: string) => void
onDayUpdate: (employeeId: string, date: Date, data: {
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
}) => void
deletingEmployeeId: string | null
}
export function EmployeeRow({
employee,
employeeSchedules,
currentYear,
currentMonth,
onEdit,
onDelete,
onDayStatusChange,
onDayUpdate,
deletingEmployeeId
}: EmployeeRowProps) {
const [isExpanded, setIsExpanded] = useState(false)
const formatSalary = (salary?: number) => {
if (!salary) return 'Не указана'
return new Intl.NumberFormat('ru-RU').format(salary) + ' ₽'
}
const formatDate = (dateString?: string) => {
if (!dateString) return 'Не указана'
return new Date(dateString).toLocaleDateString('ru-RU')
}
// Подсчет статистики сотрудника как в календаре
const calculateEmployeeStats = () => {
const stats = {
totalHours: 0,
workDays: 0,
vacation: 0,
sick: 0,
overtime: 0,
kpi: 0
}
// Получаем данные из employeeSchedules
const scheduleData = employeeSchedules[employee.id] || []
// Получаем количество дней в текущем месяце
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
// Проходим по всем дням месяца
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(currentYear, currentMonth, day)
const dateStr = date.toISOString().split('T')[0]
const dayOfWeek = date.getDay()
// Ищем запись в БД для этого дня
const dayRecord = scheduleData.find(record =>
record.date.split('T')[0] === dateStr
)
if (dayRecord) {
// Если есть запись в БД, используем её
switch (dayRecord.status) {
case 'WORK':
stats.workDays++
stats.totalHours += dayRecord.hoursWorked || 0
stats.overtime += dayRecord.overtimeHours || 0
break
case 'VACATION':
stats.vacation++
break
case 'SICK':
stats.sick++
break
}
} else {
// Если записи нет, используем логику по умолчанию
if (dayOfWeek !== 0 && dayOfWeek !== 6) { // Не выходные
stats.workDays++
stats.totalHours += 8 // По умолчанию 8 часов
}
}
}
// Расчет KPI на основе реальных данных
const expectedWorkDays = Math.floor(daysInMonth * (5/7)) // Примерно 5 дней в неделю
const expectedHours = expectedWorkDays * 8 // 8 часов в день
if (expectedHours > 0) {
// KPI = (фактические часы / ожидаемые часы) * 100
// Учитываем также отсутствия по болезни (снижают KPI) и переработки (повышают)
const baseKPI = (stats.totalHours / expectedHours) * 100
// Штраф за больничные (каждый день -2%)
const sickPenalty = stats.sick * 2
// Бонус за переработки (каждый час +0.5%)
const overtimeBonus = stats.overtime * 0.5
// Итоговый KPI с ограничением от 0 до 100
stats.kpi = Math.max(0, Math.min(100, Math.round(baseKPI - sickPenalty + overtimeBonus)))
} else {
stats.kpi = 0
}
return stats
}
const employeeStats = calculateEmployeeStats()
return (
<Card className="glass-card p-4 !gap-0">
{/* Компактная строка сотрудника */}
<div
className="flex items-center gap-4 cursor-pointer hover:bg-white/5 rounded-lg p-2 -m-2 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
{/* Блок данных сотрудника - выделенный модуль */}
<div className="flex-shrink-0 relative group">
<div className="absolute inset-0 bg-gradient-to-r from-purple-400 to-indigo-400 rounded-lg opacity-20 group-hover:opacity-30 transition-opacity blur-sm"></div>
<div className="relative glass-card p-2 rounded-lg border border-purple-400/40 hover:border-purple-300/60 transition-all duration-200 flex items-center gap-3 h-[84px]">
{/* Аватар */}
<Avatar className="h-10 w-10 ring-2 ring-white/20">
{employee.avatar ? (
<AvatarImage
src={employee.avatar}
alt={`${employee.firstName} ${employee.lastName}`}
/>
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold text-xs">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</AvatarFallback>
</Avatar>
{/* ФИО, должность и телефон */}
<div className="flex-1 min-w-0">
<h3 className="text-white font-bold text-sm mb-0.5 truncate">
{employee.firstName} {employee.lastName}
</h3>
<p className="text-purple-200 text-xs mb-0.5 truncate">{employee.position}</p>
<div className="flex items-center text-white/60 text-xs">
<Phone className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{employee.phone}</span>
</div>
</div>
</div>
</div>
{/* Основная информация в строку */}
<div className="flex-1 flex items-center gap-6">
{/* Статистика табеля - красивые карточки */}
<div className="flex-1 grid grid-cols-6 gap-2">
{/* Часов */}
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-purple-400 to-indigo-400 rounded-lg opacity-20 group-hover:opacity-30 transition-opacity blur-sm"></div>
<div className="relative glass-card p-2 rounded-lg border border-purple-400/40 hover:border-purple-300/60 transition-all duration-200 text-center">
<div className="relative w-6 h-6 mx-auto mb-1">
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 36 36">
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth={3}
/>
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="url(#gradient-purple-bright)"
strokeWidth={3}
strokeDasharray={`${(employeeStats.totalHours / 200) * 100}, 100`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<Clock className="h-3 w-3 text-purple-300" />
</div>
</div>
<div className="text-white font-bold text-sm mb-0.5">{employeeStats.totalHours}ч</div>
<p className="text-purple-200 text-xs">Часов</p>
</div>
</div>
{/* Рабочих */}
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-green-400 to-emerald-400 rounded-lg opacity-20 group-hover:opacity-30 transition-opacity blur-sm"></div>
<div className="relative glass-card p-2 rounded-lg border border-green-400/40 hover:border-green-300/60 transition-all duration-200 text-center">
<div className="relative w-6 h-6 mx-auto mb-1">
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 36 36">
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth={3}
/>
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="url(#gradient-green-bright)"
strokeWidth={3}
strokeDasharray={`${(employeeStats.workDays / 25) * 100}, 100`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<CheckCircle className="h-3 w-3 text-green-300" />
</div>
</div>
<div className="text-white font-bold text-sm mb-0.5">{employeeStats.workDays}</div>
<p className="text-green-200 text-xs">Рабочих</p>
</div>
</div>
{/* Отпуск */}
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-blue-400 to-cyan-400 rounded-lg opacity-20 group-hover:opacity-30 transition-opacity blur-sm"></div>
<div className="relative glass-card p-2 rounded-lg border border-blue-400/40 hover:border-blue-300/60 transition-all duration-200 text-center">
<div className="relative w-6 h-6 mx-auto mb-1">
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 36 36">
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth={3}
/>
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="url(#gradient-blue-bright)"
strokeWidth={3}
strokeDasharray={`${(employeeStats.vacation / 5) * 100}, 100`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<Plane className="h-3 w-3 text-blue-300" />
</div>
</div>
<div className="text-white font-bold text-sm mb-0.5">{employeeStats.vacation}</div>
<p className="text-blue-200 text-xs">Отпуск</p>
</div>
</div>
{/* Больничный */}
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-orange-400 to-red-400 rounded-lg opacity-20 group-hover:opacity-30 transition-opacity blur-sm"></div>
<div className="relative glass-card p-2 rounded-lg border border-orange-400/40 hover:border-orange-300/60 transition-all duration-200 text-center">
<div className="relative w-6 h-6 mx-auto mb-1">
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 36 36">
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth={3}
/>
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="url(#gradient-orange-bright)"
strokeWidth={3}
strokeDasharray={`${(employeeStats.sick / 3) * 100}, 100`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<Heart className="h-3 w-3 text-orange-300" />
</div>
</div>
<div className="text-white font-bold text-sm mb-0.5">{employeeStats.sick}</div>
<p className="text-orange-200 text-xs">Больничный</p>
</div>
</div>
{/* Переработка */}
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-yellow-400 to-orange-400 rounded-lg opacity-20 group-hover:opacity-30 transition-opacity blur-sm"></div>
<div className="relative glass-card p-2 rounded-lg border border-yellow-400/40 hover:border-yellow-300/60 transition-all duration-200 text-center">
<div className="relative w-6 h-6 mx-auto mb-1">
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 36 36">
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth={3}
/>
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="url(#gradient-yellow-bright)"
strokeWidth={3}
strokeDasharray={`${(employeeStats.overtime / 20) * 100}, 100`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<Zap className="h-3 w-3 text-yellow-300" />
</div>
</div>
<div className="text-white font-bold text-sm mb-0.5">{employeeStats.overtime}ч</div>
<p className="text-yellow-200 text-xs">Переработка</p>
</div>
</div>
{/* KPI */}
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-pink-400 to-purple-400 rounded-lg opacity-20 group-hover:opacity-30 transition-opacity blur-sm"></div>
<div className="relative glass-card p-2 rounded-lg border border-pink-400/40 hover:border-pink-300/60 transition-all duration-200 text-center">
<div className="relative w-6 h-6 mx-auto mb-1">
<svg className="w-6 h-6 transform -rotate-90" viewBox="0 0 36 36">
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth={3}
/>
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="url(#gradient-pink-bright)"
strokeWidth={3}
strokeDasharray={`${employeeStats.kpi}, 100`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<Activity className="h-3 w-3 text-pink-300" />
</div>
</div>
<div className="text-white font-bold text-sm mb-0.5">{employeeStats.kpi}%</div>
<p className="text-pink-200 text-xs">KPI</p>
</div>
</div>
</div>
</div>
</div>
{/* Развернутая секция с табелем */}
{isExpanded && (
<div className="mt-2 pt-2 border-t border-white/10">
{/* Дополнительная информация и управление */}
<div className="mb-2 p-2 bg-white/5 rounded-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-wrap text-xs text-white/70">
{/* Email */}
<div className="flex items-center">
<Mail className="h-3 w-3 mr-1 flex-shrink-0" />
<span>{employee.email || 'Не указан'}</span>
</div>
{/* Зарплата */}
<div className="flex items-center">
<Briefcase className="h-3 w-3 mr-1 flex-shrink-0" />
<span>{formatSalary(employee.salary)}</span>
</div>
{/* Дата приема */}
<div className="flex items-center">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span>Принят: {formatDate(employee.hireDate)}</span>
</div>
{/* Дата рождения */}
{employee.birthDate && (
<div className="flex items-center">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span>Родился: {formatDate(employee.birthDate)}</span>
</div>
)}
{/* Telegram */}
{employee.telegram && (
<div className="flex items-center">
<MessageCircle className="h-3 w-3 mr-1 flex-shrink-0" />
<span>TG: {employee.telegram}</span>
</div>
)}
{/* WhatsApp */}
{employee.whatsapp && (
<div className="flex items-center">
<Phone className="h-3 w-3 mr-1 flex-shrink-0" />
<span>WA: {employee.whatsapp}</span>
</div>
)}
</div>
{/* Кнопки управления */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation()
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"
onClick={(e) => e.stopPropagation()}
>
<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>
{/* Табель работы и статистика - компактно */}
<div className="space-y-3">
<EmployeeCalendar
employeeId={employee.id}
employeeSchedules={employeeSchedules}
currentYear={currentYear}
currentMonth={currentMonth}
onDayStatusChange={onDayStatusChange}
onDayUpdate={onDayUpdate}
employeeName={`${employee.firstName} ${employee.lastName}`}
/>
</div>
</div>
)}
{/* SVG градиенты для статистики */}
<svg width="0" height="0">
<defs>
<linearGradient id="gradient-purple-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#A855F7" />
<stop offset="100%" stopColor="#7C3AED" />
</linearGradient>
<linearGradient id="gradient-green-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#34D399" />
<stop offset="100%" stopColor="#10B981" />
</linearGradient>
<linearGradient id="gradient-blue-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#60A5FA" />
<stop offset="100%" stopColor="#22D3EE" />
</linearGradient>
<linearGradient id="gradient-orange-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FB923C" />
<stop offset="100%" stopColor="#F87171" />
</linearGradient>
<linearGradient id="gradient-yellow-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FBBF24" />
<stop offset="100%" stopColor="#FB923C" />
</linearGradient>
<linearGradient id="gradient-pink-bright" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#F472B6" />
<stop offset="100%" stopColor="#F87171" />
</linearGradient>
</defs>
</svg>
</Card>
)
}

View File

@ -1,8 +1,6 @@
"use client"
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Search } from 'lucide-react'
interface EmployeeSearchProps {
searchQuery: string
@ -11,16 +9,11 @@ interface EmployeeSearchProps {
export function EmployeeSearch({ searchQuery, onSearchChange }: EmployeeSearchProps) {
return (
<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>
<Input
placeholder="Поиск сотрудников..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="glass-input h-10"
/>
)
}

View File

@ -5,20 +5,22 @@ import { useQuery, useMutation } from '@apollo/client'
import { apolloClient } from '@/lib/apollo-client'
import { Sidebar } from '@/components/dashboard/sidebar'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { EmployeeInlineForm } from './employee-inline-form'
import { EmployeeCompactForm } from './employee-compact-form'
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
import { EmployeeHeader } from './employee-header'
import { EmployeeSearch } from './employee-search'
import { EmployeeLegend } from './employee-legend'
import { MonthNavigation } from './month-navigation'
import { EmployeeEmptyState } from './employee-empty-state'
import { EmployeeItem } from './employee-item'
import { EmployeeRow } from './employee-row'
import { EmployeeReports } from './employee-reports'
import { toast } from 'sonner'
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
import { Users, FileText } from 'lucide-react'
import { Users, FileText, Plus, Layout, LayoutGrid } from 'lucide-react'
// Интерфейс сотрудника
interface Employee {
@ -51,13 +53,15 @@ interface Employee {
export function EmployeesDashboard() {
const [searchQuery, setSearchQuery] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [showCompactForm, setShowCompactForm] = useState(true) // По умолчанию компактная форма
const [showEditForm, setShowEditForm] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
const [currentYear] = useState(new Date().getFullYear())
const [currentMonth] = useState(new Date().getMonth())
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth())
const [activeTab, setActiveTab] = useState('combined')
interface ScheduleRecord {
id: string
@ -254,6 +258,71 @@ export function EmployeesDashboard() {
}
}
// Функция для обновления данных дня из модалки
const updateDayData = async (employeeId: string, date: Date, data: {
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
}) => {
try {
// Отправляем мутацию
await updateEmployeeSchedule({
variables: {
input: {
employeeId: employeeId,
date: date.toISOString().split('T')[0], // YYYY-MM-DD формат
status: data.status,
hoursWorked: data.hoursWorked,
overtimeHours: data.overtimeHours,
notes: data.notes
}
}
})
// Обновляем локальное состояние
const dateStr = date.toISOString().split('T')[0]
setEmployeeSchedules(prev => {
const currentSchedule = prev[employeeId] || []
const existingRecordIndex = currentSchedule.findIndex(record =>
record.date.split('T')[0] === dateStr
)
const newRecord = {
id: Date.now().toString(), // временный ID
date: date.toISOString(),
status: data.status,
hoursWorked: data.hoursWorked,
overtimeHours: data.overtimeHours,
notes: data.notes,
employee: { id: employeeId }
}
let updatedSchedule
if (existingRecordIndex >= 0) {
// Обновляем существующую запись
updatedSchedule = [...currentSchedule]
updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord }
} else {
// Добавляем новую запись
updatedSchedule = [...currentSchedule, newRecord]
}
return {
...prev,
[employeeId]: updatedSchedule
}
})
toast.success('Данные дня обновлены')
} catch (error) {
console.error('Error updating day data:', error)
toast.error('Ошибка при обновлении данных дня')
}
}
const exportToCSV = () => {
const csvContent = [
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
@ -340,25 +409,84 @@ ${employees.map((emp: Employee) =>
<Sidebar />
<main className="flex-1 ml-56 p-6">
<div className="max-w-7xl mx-auto">
{/* Заголовок страницы */}
<EmployeeHeader
showAddForm={showAddForm}
onToggleAddForm={() => setShowAddForm(!showAddForm)}
/>
{/* Панель управления с улучшенным расположением */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex items-center justify-between gap-6 mb-6">
{/* Красивые табы слева */}
<TabsList className="glass-card inline-flex h-10 items-center justify-center rounded-lg bg-white/5 p-1">
<TabsTrigger
value="combined"
className="text-white data-[state=active]:bg-white/20 cursor-pointer text-sm px-4 py-2 rounded-md transition-all"
>
<Users className="h-4 w-4 mr-2" />
Сотрудники
</TabsTrigger>
<TabsTrigger
value="reports"
className="text-white data-[state=active]:bg-white/20 cursor-pointer text-sm px-4 py-2 rounded-md transition-all"
>
<FileText className="h-4 w-4 mr-2" />
Отчеты
</TabsTrigger>
</TabsList>
{/* Поиск */}
<EmployeeSearch
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{/* Поиск и кнопки справа */}
<div className="flex items-center gap-4">
{/* Увеличенный поиск */}
<div className="w-80">
<EmployeeSearch
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
</div>
{/* Кнопки управления */}
{showAddForm && (
<Button
onClick={() => setShowCompactForm(!showCompactForm)}
variant="outline"
size="sm"
className="border-white/20 text-white/80 hover:bg-white/10 hover:text-white transition-all duration-300"
>
{showCompactForm ? (
<>
<LayoutGrid className="h-4 w-4 mr-2" />
Полная форма
</>
) : (
<>
<Layout className="h-4 w-4 mr-2" />
Компактная форма
</>
)}
</Button>
)}
<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>
</div>
{/* Форма добавления сотрудника */}
{showAddForm && (
<EmployeeInlineForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
showCompactForm ? (
<EmployeeCompactForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
) : (
<EmployeeInlineForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
)
)}
{/* Форма редактирования сотрудника */}
@ -374,72 +502,106 @@ ${employees.map((emp: Employee) =>
/>
)}
{/* Основной контент с вкладками */}
<Tabs defaultValue="combined" className="w-full">
<TabsList className="glass-card mb-6 grid w-full grid-cols-2">
<TabsTrigger
value="combined"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Users className="h-4 w-4 mr-2" />
Сотрудники и табель
</TabsTrigger>
<TabsTrigger
value="reports"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<FileText className="h-4 w-4 mr-2" />
Отчеты
</TabsTrigger>
</TabsList>
<TabsContent value="combined">
<Card className="glass-card p-6">
{(() => {
const filteredEmployees = employees.filter((employee: Employee) =>
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.position.toLowerCase().includes(searchQuery.toLowerCase())
)
if (filteredEmployees.length === 0) {
return (
<EmployeeEmptyState
searchQuery={searchQuery}
onShowAddForm={() => setShowAddForm(true)}
/>
)
}
{/* Контент табов */}
<TabsContent value="combined">
<Card className="glass-card p-6">
{(() => {
const filteredEmployees = employees.filter((employee: Employee) =>
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.position.toLowerCase().includes(searchQuery.toLowerCase())
)
if (filteredEmployees.length === 0) {
return (
<div className="space-y-6">
{/* Навигация по месяцам */}
<MonthNavigation
currentYear={currentYear}
currentMonth={currentMonth}
/>
<EmployeeEmptyState
searchQuery={searchQuery}
onShowAddForm={() => setShowAddForm(true)}
/>
)
}
{/* Легенда статусов */}
return (
<div className="space-y-6">
{/* Навигация по месяцам и легенда в одной строке */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h3 className="text-white font-medium text-lg capitalize">
{new Date().toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</h3>
{/* Кнопки навигации */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const newDate = new Date(currentYear, currentMonth - 1)
setCurrentYear(newDate.getFullYear())
setCurrentMonth(newDate.getMonth())
}}
>
</Button>
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const today = new Date()
setCurrentYear(today.getFullYear())
setCurrentMonth(today.getMonth())
}}
>
Сегодня
</Button>
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const newDate = new Date(currentYear, currentMonth + 1)
setCurrentYear(newDate.getFullYear())
setCurrentMonth(newDate.getMonth())
}}
>
</Button>
</div>
</div>
{/* Легенда статусов справа */}
<EmployeeLegend />
</div>
{/* Объединенный список сотрудников с табелем */}
{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>
{filteredEmployees.map((employee: Employee, index: number) => (
<div key={employee.id} className={index < filteredEmployees.length - 1 ? "mb-4" : ""}>
<EmployeeRow
employee={employee}
employeeSchedules={employeeSchedules}
currentYear={currentYear}
currentMonth={currentMonth}
onEdit={handleEditEmployee}
onDelete={handleEmployeeDeleted}
onDayStatusChange={changeDayStatus}
onDayUpdate={updateDayData}
deletingEmployeeId={deletingEmployeeId}
/>
</div>
))}
</div>
)
})()}
</Card>
</TabsContent>
</div>
)
})()}
</Card>
</TabsContent>
<TabsContent value="reports">
<EmployeeReports

View File

@ -81,6 +81,7 @@ interface UpdateScheduleInput {
date: string;
status: "WORK" | "WEEKEND" | "VACATION" | "SICK" | "ABSENT";
hoursWorked?: number;
overtimeHours?: number;
notes?: string;
}
@ -4794,11 +4795,13 @@ export const resolvers = {
date: new Date(args.input.date),
status: args.input.status,
hoursWorked: args.input.hoursWorked,
overtimeHours: args.input.overtimeHours,
notes: args.input.notes,
},
update: {
status: args.input.status,
hoursWorked: args.input.hoursWorked,
overtimeHours: args.input.overtimeHours,
notes: args.input.notes,
},
});

View File

@ -795,6 +795,7 @@ export const typeDefs = gql`
date: DateTime!
status: ScheduleStatus!
hoursWorked: Float
overtimeHours: Float
notes: String
employee: Employee!
createdAt: DateTime!
@ -863,6 +864,7 @@ export const typeDefs = gql`
date: DateTime!
status: ScheduleStatus!
hoursWorked: Float
overtimeHours: Float
notes: String
}