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:
@ -331,15 +331,16 @@ model Employee {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model EmployeeSchedule {
|
model EmployeeSchedule {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
date DateTime
|
date DateTime
|
||||||
status ScheduleStatus
|
status ScheduleStatus
|
||||||
hoursWorked Float?
|
hoursWorked Float?
|
||||||
notes String?
|
overtimeHours Float?
|
||||||
employeeId String
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
employeeId String
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
updatedAt DateTime @updatedAt
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([employeeId, date])
|
@@unique([employeeId, date])
|
||||||
@@map("employee_schedules")
|
@@map("employee_schedules")
|
||||||
|
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"
|
"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 {
|
interface ScheduleRecord {
|
||||||
id: string
|
id: string
|
||||||
date: string
|
date: string
|
||||||
status: string
|
status: string
|
||||||
hoursWorked?: number
|
hoursWorked?: number
|
||||||
|
overtimeHours?: number
|
||||||
|
notes?: string
|
||||||
employee: {
|
employee: {
|
||||||
id: string
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,6 +25,13 @@ interface EmployeeCalendarProps {
|
|||||||
currentYear: number
|
currentYear: number
|
||||||
currentMonth: number
|
currentMonth: number
|
||||||
onDayStatusChange: (employeeId: string, day: number, currentStatus: 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
|
||||||
|
employeeName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmployeeCalendar({
|
export function EmployeeCalendar({
|
||||||
@ -25,8 +39,16 @@ export function EmployeeCalendar({
|
|||||||
employeeSchedules,
|
employeeSchedules,
|
||||||
currentYear,
|
currentYear,
|
||||||
currentMonth,
|
currentMonth,
|
||||||
onDayStatusChange
|
onDayStatusChange,
|
||||||
|
onDayUpdate,
|
||||||
|
employeeName
|
||||||
}: EmployeeCalendarProps) {
|
}: 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()
|
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
|
||||||
|
|
||||||
@ -45,43 +67,129 @@ export function EmployeeCalendar({
|
|||||||
return dayRecord.status.toLowerCase()
|
return dayRecord.status.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если записи нет, устанавливаем дефолтный статус
|
// Если записи нет, устанавливаем дефолтный статус с вариативностью
|
||||||
const dayOfWeek = date.getDay()
|
const dayOfWeek = date.getDay()
|
||||||
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
|
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) {
|
const getDayData = (day: number) => {
|
||||||
case 'work':
|
const date = new Date(currentYear, currentMonth, day)
|
||||||
return 'bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80'
|
const dateStr = date.toISOString().split('T')[0]
|
||||||
case 'weekend':
|
|
||||||
return 'bg-purple-500/20 text-purple-300/70 border-purple-400/80'
|
const scheduleData = employeeSchedules[employeeId] || []
|
||||||
case 'vacation':
|
const dayRecord = scheduleData.find(record =>
|
||||||
return 'bg-blue-500/20 text-blue-300/70 border-blue-400/80'
|
record.date.split('T')[0] === dateStr
|
||||||
case 'sick':
|
)
|
||||||
return 'bg-yellow-500/20 text-yellow-300/70 border-yellow-400/80'
|
|
||||||
case 'absent':
|
return {
|
||||||
return 'bg-red-500/20 text-red-300/70 border-red-400/80'
|
status: dayRecord?.status || 'WORK',
|
||||||
default:
|
hoursWorked: dayRecord?.hoursWorked || 8,
|
||||||
return 'bg-white/10 text-white/50 border-white/20'
|
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) {
|
switch (status) {
|
||||||
case 'work':
|
case 'work':
|
||||||
return <CheckCircle className="h-3 w-3" />
|
return 'Рабочий день'
|
||||||
case 'weekend':
|
case 'weekend':
|
||||||
return <Clock className="h-3 w-3" />
|
return 'Выходной'
|
||||||
case 'vacation':
|
case 'vacation':
|
||||||
return <Plane className="h-3 w-3" />
|
return 'Отпуск'
|
||||||
case 'sick':
|
case 'sick':
|
||||||
return <Activity className="h-3 w-3" />
|
return 'Больничный'
|
||||||
case 'absent':
|
case 'absent':
|
||||||
return <XCircle className="h-3 w-3" />
|
return 'Прогул'
|
||||||
default:
|
default:
|
||||||
return null
|
return 'Не определен'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,62 +208,199 @@ export function EmployeeCalendar({
|
|||||||
calendarDays.push(day)
|
calendarDays.push(day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<Calendar className="h-4 w-4" />
|
<h4 className="text-white/80 font-medium flex items-center gap-2 text-xs">
|
||||||
Табель работы за {new Date(currentYear, currentMonth).toLocaleDateString('ru-RU', { month: 'long' })}
|
<Calendar className="h-3 w-3" />
|
||||||
</h4>
|
Табель за {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) => {
|
{calendarDays.map((day, index) => {
|
||||||
if (day === null) {
|
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 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 &&
|
const isToday = new Date().getDate() === day &&
|
||||||
new Date().getMonth() === currentMonth &&
|
new Date().getMonth() === currentMonth &&
|
||||||
new Date().getFullYear() === currentYear
|
new Date().getFullYear() === currentYear
|
||||||
|
const isSelected = isDateSelected(day)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${employeeId}-${day}`}
|
key={`${employeeId}-${day}`}
|
||||||
className={`
|
onClick={() => handleDayClick(day)}
|
||||||
relative p-2 min-h-[60px] border rounded-lg cursor-pointer
|
className={`relative group cursor-pointer transition-all duration-300 transform hover:scale-105 ${
|
||||||
transition-transform duration-150 hover:scale-105 active:scale-95
|
isSelected
|
||||||
${getCellStyle(status)}
|
? "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"
|
||||||
${isToday ? 'ring-2 ring-white/50' : ''}
|
: 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"
|
||||||
onClick={() => onDayStatusChange(employeeId, day, status)}
|
: 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 flex-col items-center justify-center h-full">
|
||||||
<div className="flex items-center gap-1 mb-1">
|
<span className="text-white font-medium text-xs mb-0.5">
|
||||||
{getStatusIcon(status)}
|
{day}
|
||||||
<span className="font-semibold text-sm">{day}</span>
|
</span>
|
||||||
</div>
|
|
||||||
{hours > 0 && (
|
{status === "work" && (
|
||||||
<span className="text-xs opacity-80">{hours}ч</span>
|
<div className="flex items-center space-x-0.5 text-xs">
|
||||||
|
<span className="text-white/90 text-xs">{hours}ч</span>
|
||||||
|
{overtime > 0 && (
|
||||||
|
<span className="text-yellow-300 text-xs">+{overtime}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status !== "work" && status !== "weekend" && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
status === "vacation"
|
||||||
|
? "bg-gradient-to-r from-blue-400 to-cyan-400"
|
||||||
|
: status === "sick"
|
||||||
|
? "bg-gradient-to-r from-amber-400 to-orange-400"
|
||||||
|
: "bg-gradient-to-r from-red-400 to-rose-400"
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isToday && (
|
{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>
|
</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>
|
</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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import Image from 'next/image'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import {
|
import {
|
||||||
Camera,
|
|
||||||
User,
|
User,
|
||||||
X,
|
|
||||||
Save,
|
|
||||||
UserPen,
|
UserPen,
|
||||||
Phone,
|
AlertCircle,
|
||||||
Mail,
|
Save,
|
||||||
Briefcase,
|
X,
|
||||||
DollarSign,
|
Camera,
|
||||||
FileText,
|
RefreshCw
|
||||||
MessageCircle
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
formatPhoneInput,
|
||||||
|
formatSalary,
|
||||||
|
formatNameInput,
|
||||||
|
isValidEmail,
|
||||||
|
isValidPhone,
|
||||||
|
isValidSalary,
|
||||||
|
isValidBirthDate
|
||||||
|
} from '@/lib/input-masks'
|
||||||
|
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string
|
id: string
|
||||||
@ -71,6 +72,10 @@ interface EmployeeEditInlineFormProps {
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ValidationErrors {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = false }: EmployeeEditInlineFormProps) {
|
export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = false }: EmployeeEditInlineFormProps) {
|
||||||
// Функция для форматирования даты из ISO в YYYY-MM-DD
|
// Функция для форматирования даты из ISO в YYYY-MM-DD
|
||||||
const formatDateForInput = (dateString?: string) => {
|
const formatDateForInput = (dateString?: string) => {
|
||||||
@ -95,16 +100,125 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
|
|||||||
passportPhoto: employee.passportPhoto || ''
|
passportPhoto: employee.passportPhoto || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<ValidationErrors>({})
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||||
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
|
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
|
||||||
const [showPassportPreview, setShowPassportPreview] = useState(false)
|
|
||||||
const avatarInputRef = useRef<HTMLInputElement>(null)
|
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||||
const passportInputRef = 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) => {
|
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 => ({
|
setFormData(prev => ({
|
||||||
...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
|
let endpoint: string
|
||||||
|
|
||||||
if (type === 'avatar') {
|
if (type === 'avatar') {
|
||||||
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
|
formDataUpload.append('userId', `temp_${Date.now()}`)
|
||||||
endpoint = '/api/upload-avatar'
|
endpoint = '/api/upload-avatar'
|
||||||
} else {
|
} else {
|
||||||
formDataUpload.append('documentType', 'passport')
|
// Для фото паспорта используем специальный endpoint для документов
|
||||||
|
formDataUpload.append('documentType', 'passport-photo')
|
||||||
endpoint = '/api/upload-employee-document'
|
endpoint = '/api/upload-employee-document'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,45 +253,41 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
|
|||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Неизвестная ошибка при загрузке')
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url
|
[type === 'avatar' ? 'avatar' : 'passportPhoto']: result.url
|
||||||
}))
|
}))
|
||||||
|
|
||||||
toast.success(`${type === 'avatar' ? 'Фото' : 'Паспорт'} успешно загружен`)
|
toast.success(`${type === 'avatar' ? 'Аватар' : 'Фото паспорта'} успешно загружен`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error uploading ${type}:`, 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)
|
toast.error(errorMessage)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatPhoneInput = (value: string) => {
|
const validateForm = (): boolean => {
|
||||||
const cleaned = value.replace(/\D/g, '')
|
const newErrors: ValidationErrors = {}
|
||||||
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)}`
|
Object.keys(formData).forEach(field => {
|
||||||
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
const error = validateField(field, formData[field as keyof typeof formData])
|
||||||
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
|
if (error) {
|
||||||
|
newErrors[field] = error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).filter(key => newErrors[key]).length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
// Валидация обязательных полей
|
if (!validateForm()) {
|
||||||
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
|
toast.error('Исправьте ошибки в форме')
|
||||||
toast.error('Пожалуйста, заполните все обязательные поля')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
|
|
||||||
toast.error('Введите корректный email адрес')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,12 +296,12 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
|
|||||||
firstName: formData.firstName,
|
firstName: formData.firstName,
|
||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
middleName: formData.middleName || undefined,
|
middleName: formData.middleName || undefined,
|
||||||
birthDate: formData.birthDate || undefined,
|
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
email: formData.email || undefined,
|
email: formData.email || undefined,
|
||||||
position: formData.position,
|
position: formData.position,
|
||||||
salary: formData.salary || undefined,
|
salary: formData.salary || undefined,
|
||||||
avatar: formData.avatar || undefined,
|
avatar: formData.avatar || undefined,
|
||||||
|
birthDate: formData.birthDate || undefined,
|
||||||
telegram: formData.telegram || undefined,
|
telegram: formData.telegram || undefined,
|
||||||
whatsapp: formData.whatsapp || undefined,
|
whatsapp: formData.whatsapp || undefined,
|
||||||
passportPhoto: formData.passportPhoto || undefined
|
passportPhoto: formData.passportPhoto || undefined
|
||||||
@ -199,6 +310,17 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
|
|||||||
onSave(employeeData)
|
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 getInitials = () => {
|
||||||
const first = formData.firstName.charAt(0).toUpperCase()
|
const first = formData.firstName.charAt(0).toUpperCase()
|
||||||
const last = formData.lastName.charAt(0).toUpperCase()
|
const last = formData.lastName.charAt(0).toUpperCase()
|
||||||
@ -206,361 +328,242 @@ export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="glass-card mb-6">
|
<Card className="glass-card p-4 mb-6">
|
||||||
<CardHeader className="pb-4">
|
<form onSubmit={handleSubmit}>
|
||||||
<CardTitle className="text-white text-xl flex items-center gap-3">
|
<div className="space-y-4">
|
||||||
<UserPen className="h-6 w-6 text-purple-400" />
|
{/* Заголовок */}
|
||||||
Редактировать сотрудника: {employee.firstName} {employee.lastName}
|
<div className="flex items-center gap-2 mb-4">
|
||||||
</CardTitle>
|
<UserPen className="h-5 w-5 text-blue-400" />
|
||||||
</CardHeader>
|
<h3 className="text-white font-semibold">Редактирование сотрудника</h3>
|
||||||
|
<span className="text-white/60 text-sm ml-2">({employee.firstName} {employee.lastName})</span>
|
||||||
<CardContent>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Фотографии */}
|
<div className="flex items-center gap-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
{/* Аватар с возможностью загрузки */}
|
||||||
{/* Фото сотрудника */}
|
<div className="flex-shrink-0">
|
||||||
<div className="space-y-3">
|
<div className="relative">
|
||||||
<Label className="text-white/80 font-medium flex items-center gap-2">
|
<Avatar className="h-12 w-12 ring-2 ring-white/20">
|
||||||
<Camera className="h-4 w-4" />
|
|
||||||
Фото сотрудника
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Avatar className="h-20 w-20 ring-2 ring-white/20">
|
|
||||||
{formData.avatar && formData.avatar.trim() !== '' ? (
|
{formData.avatar && formData.avatar.trim() !== '' ? (
|
||||||
<AvatarImage
|
<AvatarImage src={formData.avatar} alt="Аватар сотрудника" />
|
||||||
src={formData.avatar}
|
|
||||||
alt="Фото сотрудника"
|
|
||||||
onError={(e) => {
|
|
||||||
console.error('Ошибка загрузки аватара:', formData.avatar);
|
|
||||||
e.currentTarget.style.display = 'none';
|
|
||||||
}}
|
|
||||||
onLoad={() => console.log('Аватар загружен успешно:', formData.avatar)}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
|
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-blue-600 text-white font-semibold">
|
||||||
{getInitials() || <User className="h-8 w-8" />}
|
{getInitials() || <User className="h-6 w-6" />}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<div className="absolute -bottom-1 -right-1">
|
||||||
<div className="space-y-2">
|
<label htmlFor="avatar-upload-edit" className="cursor-pointer">
|
||||||
<input
|
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center hover:bg-blue-700 transition-colors">
|
||||||
ref={avatarInputRef}
|
{isUploadingAvatar ? (
|
||||||
type="file"
|
<RefreshCw className="h-2.5 w-2.5 text-white animate-spin" />
|
||||||
accept="image/*"
|
) : (
|
||||||
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
|
<Camera className="h-2.5 w-2.5 text-white" />
|
||||||
className="hidden"
|
)}
|
||||||
/>
|
</div>
|
||||||
|
</label>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Фото паспорта */}
|
{/* Основные поля в одну строку */}
|
||||||
<div className="space-y-3">
|
<div className="flex-1 grid grid-cols-6 gap-3 items-start">
|
||||||
<Label className="text-white/80 font-medium flex items-center gap-2">
|
{/* Имя */}
|
||||||
<FileText className="h-4 w-4" />
|
<div className="relative">
|
||||||
Фото паспорта
|
<Input
|
||||||
</Label>
|
value={formData.firstName}
|
||||||
|
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||||
<div className="space-y-3">
|
placeholder="Имя *"
|
||||||
{formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
|
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.firstName ? 'border-red-400' : ''}`}
|
||||||
<div className="relative">
|
required
|
||||||
<Image
|
/>
|
||||||
src={formData.passportPhoto}
|
<ErrorMessage error={errors.firstName} />
|
||||||
alt="Паспорт"
|
|
||||||
width={400}
|
|
||||||
height={300}
|
|
||||||
className="w-full h-auto max-h-48 object-contain rounded-lg border border-white/20 bg-white/5 cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
onClick={() => setShowPassportPreview(true)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setFormData(prev => ({ ...prev, passportPhoto: '' }))}
|
|
||||||
className="absolute top-2 right-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="absolute bottom-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
|
||||||
Нажмите для увеличения
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-48 border-2 border-dashed border-white/20 rounded-lg flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<FileText className="h-8 w-8 text-white/40 mx-auto mb-2" />
|
|
||||||
<p className="text-white/60 text-sm">Паспорт не загружен</p>
|
|
||||||
<p className="text-white/40 text-xs mt-1">Рекомендуемый формат: JPG, PNG</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref={passportInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => passportInputRef.current?.click()}
|
|
||||||
disabled={isUploadingPassport}
|
|
||||||
className="w-full glass-secondary text-white hover:text-white"
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4 mr-2" />
|
|
||||||
{isUploadingPassport ? 'Загрузка...' : 'Загрузить паспорт'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
<div className="relative">
|
||||||
<Label className="text-white/80 text-sm mb-2 block">
|
<Input
|
||||||
Имя <span className="text-red-400">*</span>
|
value={formData.lastName}
|
||||||
</Label>
|
onChange={(e) => handleInputChange('lastName', e.target.value)}
|
||||||
<Input
|
placeholder="Фамилия *"
|
||||||
value={formData.firstName}
|
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.lastName ? 'border-red-400' : ''}`}
|
||||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
required
|
||||||
placeholder="Александр"
|
/>
|
||||||
className="glass-input text-white placeholder:text-white/40"
|
<ErrorMessage error={errors.lastName} />
|
||||||
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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="bg-white/10" />
|
{/* Должность */}
|
||||||
|
<div className="relative">
|
||||||
{/* Контактная информация */}
|
<Input
|
||||||
<div className="space-y-4">
|
value={formData.position}
|
||||||
<Label className="text-white font-medium flex items-center gap-2">
|
onChange={(e) => handleInputChange('position', e.target.value)}
|
||||||
<Phone className="h-4 w-4" />
|
placeholder="Должность *"
|
||||||
Контактная информация
|
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.position ? 'border-red-400' : ''}`}
|
||||||
</Label>
|
required
|
||||||
|
/>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<ErrorMessage error={errors.position} />
|
||||||
<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>
|
||||||
</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>
|
<div className="relative">
|
||||||
<Label className="text-white/80 text-sm mb-2 block">
|
<Input
|
||||||
Должность <span className="text-red-400">*</span>
|
value={formData.phone}
|
||||||
</Label>
|
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||||
<Input
|
placeholder="Телефон *"
|
||||||
value={formData.position}
|
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.phone ? 'border-red-400' : ''}`}
|
||||||
onChange={(e) => handleInputChange('position', e.target.value)}
|
required
|
||||||
placeholder="Менеджер склада"
|
/>
|
||||||
className="glass-input text-white placeholder:text-white/40"
|
<ErrorMessage error={errors.phone} />
|
||||||
required
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
{/* Email */}
|
||||||
|
<div className="relative">
|
||||||
<div>
|
<Input
|
||||||
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
|
type="email"
|
||||||
<DollarSign className="h-3 w-3" />
|
value={formData.email}
|
||||||
Зарплата
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
</Label>
|
placeholder="Email"
|
||||||
<Input
|
className={`glass-input text-white placeholder:text-white/40 h-10 text-sm ${errors.email ? 'border-red-400' : ''}`}
|
||||||
type="number"
|
/>
|
||||||
min="0"
|
<ErrorMessage error={errors.email} />
|
||||||
value={formData.salary || ''}
|
</div>
|
||||||
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
|
|
||||||
placeholder="80000"
|
{/* Зарплата */}
|
||||||
className="glass-input text-white placeholder:text-white/40"
|
<div className="relative">
|
||||||
/>
|
<Input
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Кнопки управления */}
|
{/* Кнопки управления */}
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300"
|
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-10 w-10 p-0"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-2" />
|
<X className="h-4 w-4" />
|
||||||
Отмена
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
|
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
|
||||||
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
|
size="sm"
|
||||||
|
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white border-0 shadow-lg shadow-blue-500/25 hover:shadow-blue-500/40 transition-all duration-300 h-10 px-4"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Сохранение...' : (
|
{isLoading ? (
|
||||||
|
'Сохранение...'
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
Сохранить изменения
|
Сохранить
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
{/* Дополнительные поля - всегда видимы */}
|
||||||
|
<div className="mt-4 p-4 bg-white/5 rounded-lg border border-white/10">
|
||||||
|
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Дополнительная информация
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Дата рождения */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="text-white/70 text-xs mb-1 block">Дата рождения</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.birthDate}
|
||||||
|
onChange={(e) => handleInputChange('birthDate', e.target.value)}
|
||||||
|
className={`glass-input text-white h-9 text-sm ${errors.birthDate ? 'border-red-400' : ''}`}
|
||||||
|
/>
|
||||||
|
<ErrorMessage error={errors.birthDate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telegram */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="text-white/70 text-xs mb-1 block">Telegram</label>
|
||||||
|
<Input
|
||||||
|
value={formData.telegram}
|
||||||
|
onChange={(e) => handleInputChange('telegram', e.target.value)}
|
||||||
|
placeholder="@username"
|
||||||
|
className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.telegram ? 'border-red-400' : ''}`}
|
||||||
|
/>
|
||||||
|
<ErrorMessage error={errors.telegram} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WhatsApp */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="text-white/70 text-xs mb-1 block">WhatsApp</label>
|
||||||
|
<Input
|
||||||
|
value={formData.whatsapp}
|
||||||
|
onChange={(e) => handleInputChange('whatsapp', e.target.value)}
|
||||||
|
placeholder="+7 (999) 123-45-67"
|
||||||
|
className={`glass-input text-white placeholder:text-white/40 h-9 text-sm ${errors.whatsapp ? 'border-red-400' : ''}`}
|
||||||
|
/>
|
||||||
|
<ErrorMessage error={errors.whatsapp} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Фото паспорта */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="text-white/70 text-xs mb-1 block">Фото паспорта</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<div className="w-full h-9 rounded-lg border border-white/20 bg-white/5 flex items-center justify-center overflow-hidden">
|
||||||
|
{formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
|
||||||
|
<img
|
||||||
|
src={formData.passportPhoto}
|
||||||
|
alt="Фото паспорта"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-white/40 text-xs">Не загружено</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="passport-upload-edit" className="cursor-pointer">
|
||||||
|
<div className="w-9 h-9 bg-blue-600 rounded-lg flex items-center justify-center hover:bg-blue-700 transition-colors">
|
||||||
|
{isUploadingPassport ? (
|
||||||
|
<RefreshCw className="h-4 w-4 text-white animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Camera className="h-4 w-4 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Скрытые input для загрузки файлов */}
|
||||||
|
<input
|
||||||
|
id="avatar-upload-edit"
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isUploadingAvatar}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="passport-upload-edit"
|
||||||
|
ref={passportInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isUploadingPassport}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -1,14 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Users, Plus } from 'lucide-react'
|
import { Users, Plus, Layout, LayoutGrid } from 'lucide-react'
|
||||||
|
|
||||||
interface EmployeeHeaderProps {
|
interface EmployeeHeaderProps {
|
||||||
showAddForm: boolean
|
showAddForm: boolean
|
||||||
|
showCompactForm: boolean
|
||||||
onToggleAddForm: () => void
|
onToggleAddForm: () => void
|
||||||
|
onToggleFormType: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderProps) {
|
export function EmployeeHeader({
|
||||||
|
showAddForm,
|
||||||
|
showCompactForm,
|
||||||
|
onToggleAddForm,
|
||||||
|
onToggleFormType
|
||||||
|
}: EmployeeHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -18,13 +25,37 @@ export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderP
|
|||||||
<p className="text-white/70">Личные данные, табель работы и учет</p>
|
<p className="text-white/70">Личные данные, табель работы и учет</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={onToggleAddForm}
|
<div className="flex items-center gap-3">
|
||||||
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"
|
{showAddForm && (
|
||||||
>
|
<Button
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
onClick={onToggleFormType}
|
||||||
{showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
|
className="border-white/20 text-white/80 hover:bg-white/10 hover:text-white transition-all duration-300"
|
||||||
|
>
|
||||||
|
{showCompactForm ? (
|
||||||
|
<>
|
||||||
|
<LayoutGrid className="h-4 w-4 mr-2" />
|
||||||
|
Полная форма
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Layout className="h-4 w-4 mr-2" />
|
||||||
|
Компактная форма
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onToggleAddForm}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{showAddForm ? 'Скрыть форму' : 'Добавить сотрудника'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</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"
|
"use client"
|
||||||
|
|
||||||
import { Card } from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Search } from 'lucide-react'
|
|
||||||
|
|
||||||
interface EmployeeSearchProps {
|
interface EmployeeSearchProps {
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
@ -11,16 +9,11 @@ interface EmployeeSearchProps {
|
|||||||
|
|
||||||
export function EmployeeSearch({ searchQuery, onSearchChange }: EmployeeSearchProps) {
|
export function EmployeeSearch({ searchQuery, onSearchChange }: EmployeeSearchProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="glass-card p-4 mb-6">
|
<Input
|
||||||
<div className="relative">
|
placeholder="Поиск сотрудников..."
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/60 h-4 w-4" />
|
value={searchQuery}
|
||||||
<Input
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder="Поиск сотрудников..."
|
className="glass-input h-10"
|
||||||
value={searchQuery}
|
/>
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
className="glass-input pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -5,20 +5,22 @@ import { useQuery, useMutation } from '@apollo/client'
|
|||||||
import { apolloClient } from '@/lib/apollo-client'
|
import { apolloClient } from '@/lib/apollo-client'
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
import { EmployeeInlineForm } from './employee-inline-form'
|
import { EmployeeInlineForm } from './employee-inline-form'
|
||||||
|
import { EmployeeCompactForm } from './employee-compact-form'
|
||||||
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
|
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
|
||||||
import { EmployeeHeader } from './employee-header'
|
|
||||||
import { EmployeeSearch } from './employee-search'
|
import { EmployeeSearch } from './employee-search'
|
||||||
import { EmployeeLegend } from './employee-legend'
|
import { EmployeeLegend } from './employee-legend'
|
||||||
import { MonthNavigation } from './month-navigation'
|
|
||||||
import { EmployeeEmptyState } from './employee-empty-state'
|
import { EmployeeEmptyState } from './employee-empty-state'
|
||||||
import { EmployeeItem } from './employee-item'
|
import { EmployeeRow } from './employee-row'
|
||||||
import { EmployeeReports } from './employee-reports'
|
import { EmployeeReports } from './employee-reports'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
|
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
|
||||||
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
||||||
import { Users, FileText } from 'lucide-react'
|
import { Users, FileText, Plus, Layout, LayoutGrid } from 'lucide-react'
|
||||||
|
|
||||||
// Интерфейс сотрудника
|
// Интерфейс сотрудника
|
||||||
interface Employee {
|
interface Employee {
|
||||||
@ -51,13 +53,15 @@ interface Employee {
|
|||||||
export function EmployeesDashboard() {
|
export function EmployeesDashboard() {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [showCompactForm, setShowCompactForm] = useState(true) // По умолчанию компактная форма
|
||||||
const [showEditForm, setShowEditForm] = useState(false)
|
const [showEditForm, setShowEditForm] = useState(false)
|
||||||
const [createLoading, setCreateLoading] = useState(false)
|
const [createLoading, setCreateLoading] = useState(false)
|
||||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
||||||
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
|
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
|
||||||
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
|
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
|
||||||
const [currentYear] = useState(new Date().getFullYear())
|
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
|
||||||
const [currentMonth] = useState(new Date().getMonth())
|
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth())
|
||||||
|
const [activeTab, setActiveTab] = useState('combined')
|
||||||
|
|
||||||
interface ScheduleRecord {
|
interface ScheduleRecord {
|
||||||
id: string
|
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 exportToCSV = () => {
|
||||||
const csvContent = [
|
const csvContent = [
|
||||||
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
|
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
|
||||||
@ -340,25 +409,84 @@ ${employees.map((emp: Employee) =>
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 ml-56 p-6">
|
<main className="flex-1 ml-56 p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Заголовок страницы */}
|
{/* Панель управления с улучшенным расположением */}
|
||||||
<EmployeeHeader
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
showAddForm={showAddForm}
|
<div className="flex items-center justify-between gap-6 mb-6">
|
||||||
onToggleAddForm={() => setShowAddForm(!showAddForm)}
|
{/* Красивые табы слева */}
|
||||||
/>
|
<TabsList className="glass-card inline-flex h-10 items-center justify-center rounded-lg bg-white/5 p-1">
|
||||||
|
<TabsTrigger
|
||||||
|
value="combined"
|
||||||
|
className="text-white data-[state=active]:bg-white/20 cursor-pointer text-sm px-4 py-2 rounded-md transition-all"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4 mr-2" />
|
||||||
|
Сотрудники
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="reports"
|
||||||
|
className="text-white data-[state=active]:bg-white/20 cursor-pointer text-sm px-4 py-2 rounded-md transition-all"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Отчеты
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{/* Поиск */}
|
{/* Поиск и кнопки справа */}
|
||||||
<EmployeeSearch
|
<div className="flex items-center gap-4">
|
||||||
searchQuery={searchQuery}
|
{/* Увеличенный поиск */}
|
||||||
onSearchChange={setSearchQuery}
|
<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 && (
|
{showAddForm && (
|
||||||
<EmployeeInlineForm
|
showCompactForm ? (
|
||||||
onSave={handleCreateEmployee}
|
<EmployeeCompactForm
|
||||||
onCancel={() => setShowAddForm(false)}
|
onSave={handleCreateEmployee}
|
||||||
isLoading={createLoading}
|
onCancel={() => setShowAddForm(false)}
|
||||||
/>
|
isLoading={createLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmployeeInlineForm
|
||||||
|
onSave={handleCreateEmployee}
|
||||||
|
onCancel={() => setShowAddForm(false)}
|
||||||
|
isLoading={createLoading}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Форма редактирования сотрудника */}
|
{/* Форма редактирования сотрудника */}
|
||||||
@ -374,72 +502,106 @@ ${employees.map((emp: Employee) =>
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Основной контент с вкладками */}
|
{/* Контент табов */}
|
||||||
<Tabs defaultValue="combined" className="w-full">
|
<TabsContent value="combined">
|
||||||
<TabsList className="glass-card mb-6 grid w-full grid-cols-2">
|
<Card className="glass-card p-6">
|
||||||
<TabsTrigger
|
{(() => {
|
||||||
value="combined"
|
const filteredEmployees = employees.filter((employee: Employee) =>
|
||||||
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
>
|
employee.position.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
<Users className="h-4 w-4 mr-2" />
|
)
|
||||||
Сотрудники и табель
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="reports"
|
|
||||||
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4 mr-2" />
|
|
||||||
Отчеты
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="combined">
|
|
||||||
<Card className="glass-card p-6">
|
|
||||||
{(() => {
|
|
||||||
const filteredEmployees = employees.filter((employee: Employee) =>
|
|
||||||
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
employee.position.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
|
|
||||||
if (filteredEmployees.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmployeeEmptyState
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onShowAddForm={() => setShowAddForm(true)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (filteredEmployees.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<EmployeeEmptyState
|
||||||
{/* Навигация по месяцам */}
|
searchQuery={searchQuery}
|
||||||
<MonthNavigation
|
onShowAddForm={() => setShowAddForm(true)}
|
||||||
currentYear={currentYear}
|
/>
|
||||||
currentMonth={currentMonth}
|
)
|
||||||
/>
|
}
|
||||||
|
|
||||||
{/* Легенда статусов */}
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Навигация по месяцам и легенда в одной строке */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h3 className="text-white font-medium text-lg capitalize">
|
||||||
|
{new Date().toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Кнопки навигации */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||||
|
onClick={() => {
|
||||||
|
const newDate = new Date(currentYear, currentMonth - 1)
|
||||||
|
setCurrentYear(newDate.getFullYear())
|
||||||
|
setCurrentMonth(newDate.getMonth())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||||
|
onClick={() => {
|
||||||
|
const today = new Date()
|
||||||
|
setCurrentYear(today.getFullYear())
|
||||||
|
setCurrentMonth(today.getMonth())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сегодня
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="glass-secondary text-white hover:text-white h-8 px-3"
|
||||||
|
onClick={() => {
|
||||||
|
const newDate = new Date(currentYear, currentMonth + 1)
|
||||||
|
setCurrentYear(newDate.getFullYear())
|
||||||
|
setCurrentMonth(newDate.getMonth())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Легенда статусов справа */}
|
||||||
<EmployeeLegend />
|
<EmployeeLegend />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Объединенный список сотрудников с табелем */}
|
{/* Компактный список сотрудников с раскрывающимся табелем */}
|
||||||
{filteredEmployees.map((employee: Employee) => (
|
<div>
|
||||||
<EmployeeItem
|
{filteredEmployees.map((employee: Employee, index: number) => (
|
||||||
key={employee.id}
|
<div key={employee.id} className={index < filteredEmployees.length - 1 ? "mb-4" : ""}>
|
||||||
employee={employee}
|
<EmployeeRow
|
||||||
employeeSchedules={employeeSchedules}
|
employee={employee}
|
||||||
currentYear={currentYear}
|
employeeSchedules={employeeSchedules}
|
||||||
currentMonth={currentMonth}
|
currentYear={currentYear}
|
||||||
onEdit={handleEditEmployee}
|
currentMonth={currentMonth}
|
||||||
onDelete={handleEmployeeDeleted}
|
onEdit={handleEditEmployee}
|
||||||
onDayStatusChange={changeDayStatus}
|
onDelete={handleEmployeeDeleted}
|
||||||
deletingEmployeeId={deletingEmployeeId}
|
onDayStatusChange={changeDayStatus}
|
||||||
/>
|
onDayUpdate={updateDayData}
|
||||||
|
deletingEmployeeId={deletingEmployeeId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})()}
|
)
|
||||||
</Card>
|
})()}
|
||||||
</TabsContent>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reports">
|
<TabsContent value="reports">
|
||||||
<EmployeeReports
|
<EmployeeReports
|
||||||
|
@ -81,6 +81,7 @@ interface UpdateScheduleInput {
|
|||||||
date: string;
|
date: string;
|
||||||
status: "WORK" | "WEEKEND" | "VACATION" | "SICK" | "ABSENT";
|
status: "WORK" | "WEEKEND" | "VACATION" | "SICK" | "ABSENT";
|
||||||
hoursWorked?: number;
|
hoursWorked?: number;
|
||||||
|
overtimeHours?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4794,11 +4795,13 @@ export const resolvers = {
|
|||||||
date: new Date(args.input.date),
|
date: new Date(args.input.date),
|
||||||
status: args.input.status,
|
status: args.input.status,
|
||||||
hoursWorked: args.input.hoursWorked,
|
hoursWorked: args.input.hoursWorked,
|
||||||
|
overtimeHours: args.input.overtimeHours,
|
||||||
notes: args.input.notes,
|
notes: args.input.notes,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
status: args.input.status,
|
status: args.input.status,
|
||||||
hoursWorked: args.input.hoursWorked,
|
hoursWorked: args.input.hoursWorked,
|
||||||
|
overtimeHours: args.input.overtimeHours,
|
||||||
notes: args.input.notes,
|
notes: args.input.notes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -795,6 +795,7 @@ export const typeDefs = gql`
|
|||||||
date: DateTime!
|
date: DateTime!
|
||||||
status: ScheduleStatus!
|
status: ScheduleStatus!
|
||||||
hoursWorked: Float
|
hoursWorked: Float
|
||||||
|
overtimeHours: Float
|
||||||
notes: String
|
notes: String
|
||||||
employee: Employee!
|
employee: Employee!
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
@ -863,6 +864,7 @@ export const typeDefs = gql`
|
|||||||
date: DateTime!
|
date: DateTime!
|
||||||
status: ScheduleStatus!
|
status: ScheduleStatus!
|
||||||
hoursWorked: Float
|
hoursWorked: Float
|
||||||
|
overtimeHours: Float
|
||||||
notes: String
|
notes: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user