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:
@ -335,6 +335,7 @@ model EmployeeSchedule {
|
||||
date DateTime
|
||||
status ScheduleStatus
|
||||
hoursWorked Float?
|
||||
overtimeHours Float?
|
||||
notes String?
|
||||
employeeId String
|
||||
createdAt DateTime @default(now())
|
||||
|
196
src/components/employees/bulk-edit-modal.tsx
Normal file
196
src/components/employees/bulk-edit-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
186
src/components/employees/day-edit-modal.tsx
Normal file
186
src/components/employees/day-edit-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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' })}
|
||||
<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">
|
||||
{/* Заголовки дней недели */}
|
||||
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
|
||||
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{/* Кнопки управления выделением */}
|
||||
<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">
|
||||
{/* Заголовки дней недели */}
|
||||
<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>
|
||||
<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>
|
||||
{hours > 0 && (
|
||||
<span className="text-xs opacity-80">{hours}ч</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isToday && (
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
|
||||
<div 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>
|
||||
)
|
||||
}
|
542
src/components/employees/employee-compact-form.tsx
Normal file
542
src/components/employees/employee-compact-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-20 w-20 ring-2 ring-white/20">
|
||||
{/* Аватар с возможностью загрузки */}
|
||||
<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="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-2">
|
||||
{/* Основные поля в одну строку */}
|
||||
<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-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 ? (
|
||||
'Сохранение...'
|
||||
) : (
|
||||
<>
|
||||
<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-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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</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
|
||||
id="passport-upload-edit"
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-white/10" />
|
||||
|
||||
{/* Рабочая информация */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-white font-medium flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
Рабочая информация
|
||||
</Label>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Кнопки управления */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Отмена
|
||||
</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"
|
||||
>
|
||||
{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>
|
||||
</Card>
|
||||
)
|
||||
}
|
@ -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,6 +25,29 @@ export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderP
|
||||
<p className="text-white/70">Личные данные, табель работы и учет</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
@ -26,5 +56,6 @@ export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderP
|
||||
{showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
557
src/components/employees/employee-row.tsx
Normal file
557
src/components/employees/employee-row.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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"
|
||||
className="glass-input h-10"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
||||
{/* Поиск */}
|
||||
{/* Поиск и кнопки справа */}
|
||||
<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 && (
|
||||
showCompactForm ? (
|
||||
<EmployeeCompactForm
|
||||
onSave={handleCreateEmployee}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
isLoading={createLoading}
|
||||
/>
|
||||
) : (
|
||||
<EmployeeInlineForm
|
||||
onSave={handleCreateEmployee}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
isLoading={createLoading}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Форма редактирования сотрудника */}
|
||||
@ -374,25 +502,7 @@ ${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">
|
||||
{(() => {
|
||||
@ -412,19 +522,68 @@ ${employees.map((emp: Employee) =>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Навигация по месяцам */}
|
||||
<MonthNavigation
|
||||
currentYear={currentYear}
|
||||
currentMonth={currentMonth}
|
||||
/>
|
||||
{/* Навигация по месяцам и легенда в одной строке */}
|
||||
<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}
|
||||
{/* Компактный список сотрудников с раскрывающимся табелем */}
|
||||
<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}
|
||||
@ -432,10 +591,13 @@ ${employees.map((emp: Employee) =>
|
||||
onEdit={handleEditEmployee}
|
||||
onDelete={handleEmployeeDeleted}
|
||||
onDayStatusChange={changeDayStatus}
|
||||
onDayUpdate={updateDayData}
|
||||
deletingEmployeeId={deletingEmployeeId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Card>
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user