-
-
-
-
-
-
- {/* Рабочая информация */}
-
-
-
{/* Кнопки управления */}
-
+
-
-
-
- {/* Превью паспорта */}
-
+
+ {/* Дополнительные поля - всегда видимы */}
+
+
+
+ Дополнительная информация
+
+
+
+ {/* Дата рождения */}
+
+
+ handleInputChange('birthDate', e.target.value)}
+ className={`glass-input text-white h-9 text-sm ${errors.birthDate ? 'border-red-400' : ''}`}
+ />
+
+
+
+ {/* Telegram */}
+
+
+ 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' : ''}`}
+ />
+
+
+
+ {/* WhatsApp */}
+
+
+ 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' : ''}`}
+ />
+
+
+
+ {/* Фото паспорта */}
+
+
+
+
+
+ {formData.passportPhoto && formData.passportPhoto.trim() !== '' ? (
+

+ ) : (
+
Не загружено
+ )}
+
+
+
+
+
+
+
+
+ {/* Скрытые input для загрузки файлов */}
+
e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
+ className="hidden"
+ disabled={isUploadingAvatar}
+ />
+
+
e.target.files?.[0] && handleFileUpload(e.target.files[0], 'passport')}
+ className="hidden"
+ disabled={isUploadingPassport}
+ />
+
+
)
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/components/employees/employee-header.tsx b/src/components/employees/employee-header.tsx
index 80c957d..241edbf 100644
--- a/src/components/employees/employee-header.tsx
+++ b/src/components/employees/employee-header.tsx
@@ -1,14 +1,21 @@
"use client"
import { Button } from '@/components/ui/button'
-import { Users, Plus } from 'lucide-react'
+import { Users, Plus, Layout, LayoutGrid } from 'lucide-react'
interface EmployeeHeaderProps {
showAddForm: boolean
+ showCompactForm: boolean
onToggleAddForm: () => void
+ onToggleFormType: () => void
}
-export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderProps) {
+export function EmployeeHeader({
+ showAddForm,
+ showCompactForm,
+ onToggleAddForm,
+ onToggleFormType
+}: EmployeeHeaderProps) {
return (
@@ -18,13 +25,37 @@ export function EmployeeHeader({ showAddForm, onToggleAddForm }: EmployeeHeaderP
Личные данные, табель работы и учет
-
+
+
+ {showAddForm && (
+
+ )}
+
+
+
)
}
\ No newline at end of file
diff --git a/src/components/employees/employee-row.tsx b/src/components/employees/employee-row.tsx
new file mode 100644
index 0000000..8a94969
--- /dev/null
+++ b/src/components/employees/employee-row.tsx
@@ -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 (
+
+ {/* Компактная строка сотрудника */}
+ setIsExpanded(!isExpanded)}
+ >
+ {/* Блок данных сотрудника - выделенный модуль */}
+
+
+
+ {/* Аватар */}
+
+ {employee.avatar ? (
+
+ ) : null}
+
+ {employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
+
+
+
+ {/* ФИО, должность и телефон */}
+
+
+ {employee.firstName} {employee.lastName}
+
+
{employee.position}
+
+
+
+
+
+ {/* Основная информация в строку */}
+
+
+ {/* Статистика табеля - красивые карточки */}
+
+ {/* Часов */}
+
+
+
+
+
{employeeStats.totalHours}ч
+
Часов
+
+
+
+ {/* Рабочих */}
+
+
+
+
+
{employeeStats.workDays}
+
Рабочих
+
+
+
+ {/* Отпуск */}
+
+
+
+
+
{employeeStats.vacation}
+
Отпуск
+
+
+
+ {/* Больничный */}
+
+
+
+
+
{employeeStats.sick}
+
Больничный
+
+
+
+ {/* Переработка */}
+
+
+
+
+
{employeeStats.overtime}ч
+
Переработка
+
+
+
+ {/* KPI */}
+
+
+
+
+
{employeeStats.kpi}%
+
KPI
+
+
+
+
+
+
+
+ {/* Развернутая секция с табелем */}
+ {isExpanded && (
+
+ {/* Дополнительная информация и управление */}
+
+
+
+ {/* Email */}
+
+
+ {employee.email || 'Не указан'}
+
+
+ {/* Зарплата */}
+
+
+ {formatSalary(employee.salary)}
+
+
+ {/* Дата приема */}
+
+
+ Принят: {formatDate(employee.hireDate)}
+
+
+ {/* Дата рождения */}
+ {employee.birthDate && (
+
+
+ Родился: {formatDate(employee.birthDate)}
+
+ )}
+
+ {/* Telegram */}
+ {employee.telegram && (
+
+
+ TG: {employee.telegram}
+
+ )}
+
+ {/* WhatsApp */}
+ {employee.whatsapp && (
+
+
+
WA: {employee.whatsapp}
+
+ )}
+
+
+ {/* Кнопки управления */}
+
+
+
+
+
+
+
+
+
+ Уволить сотрудника?
+
+ Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}?
+ Это действие нельзя отменить.
+
+
+
+
+ Отмена
+
+ onDelete(employee.id)}
+ disabled={deletingEmployeeId === employee.id}
+ className="bg-red-600 hover:bg-red-700 text-white"
+ >
+ {deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'}
+
+
+
+
+
+
+
+
+ {/* Табель работы и статистика - компактно */}
+
+
+
+
+ )}
+ {/* SVG градиенты для статистики */}
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/employees/employee-search.tsx b/src/components/employees/employee-search.tsx
index 8729964..2c21a0c 100644
--- a/src/components/employees/employee-search.tsx
+++ b/src/components/employees/employee-search.tsx
@@ -1,8 +1,6 @@
"use client"
-import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
-import { Search } from 'lucide-react'
interface EmployeeSearchProps {
searchQuery: string
@@ -11,16 +9,11 @@ interface EmployeeSearchProps {
export function EmployeeSearch({ searchQuery, onSearchChange }: EmployeeSearchProps) {
return (
-
-
-
- onSearchChange(e.target.value)}
- className="glass-input pl-10"
- />
-
-
+
onSearchChange(e.target.value)}
+ className="glass-input h-10"
+ />
)
}
\ No newline at end of file
diff --git a/src/components/employees/employees-dashboard.tsx b/src/components/employees/employees-dashboard.tsx
index 0938732..125d103 100644
--- a/src/components/employees/employees-dashboard.tsx
+++ b/src/components/employees/employees-dashboard.tsx
@@ -5,20 +5,22 @@ import { useQuery, useMutation } from '@apollo/client'
import { apolloClient } from '@/lib/apollo-client'
import { Sidebar } from '@/components/dashboard/sidebar'
import { Card } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+
import { EmployeeInlineForm } from './employee-inline-form'
+import { EmployeeCompactForm } from './employee-compact-form'
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
-import { EmployeeHeader } from './employee-header'
+
import { EmployeeSearch } from './employee-search'
import { EmployeeLegend } from './employee-legend'
-import { MonthNavigation } from './month-navigation'
import { EmployeeEmptyState } from './employee-empty-state'
-import { EmployeeItem } from './employee-item'
+import { EmployeeRow } from './employee-row'
import { EmployeeReports } from './employee-reports'
import { toast } from 'sonner'
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
-import { Users, FileText } from 'lucide-react'
+import { Users, FileText, Plus, Layout, LayoutGrid } from 'lucide-react'
// Интерфейс сотрудника
interface Employee {
@@ -51,13 +53,15 @@ interface Employee {
export function EmployeesDashboard() {
const [searchQuery, setSearchQuery] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
+ const [showCompactForm, setShowCompactForm] = useState(true) // По умолчанию компактная форма
const [showEditForm, setShowEditForm] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [editingEmployee, setEditingEmployee] = useState
(null)
const [deletingEmployeeId, setDeletingEmployeeId] = useState(null)
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
- const [currentYear] = useState(new Date().getFullYear())
- const [currentMonth] = useState(new Date().getMonth())
+ const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
+ const [currentMonth, setCurrentMonth] = useState(new Date().getMonth())
+ const [activeTab, setActiveTab] = useState('combined')
interface ScheduleRecord {
id: string
@@ -254,6 +258,71 @@ export function EmployeesDashboard() {
}
}
+ // Функция для обновления данных дня из модалки
+ const updateDayData = async (employeeId: string, date: Date, data: {
+ status: string
+ hoursWorked?: number
+ overtimeHours?: number
+ notes?: string
+ }) => {
+ try {
+ // Отправляем мутацию
+ await updateEmployeeSchedule({
+ variables: {
+ input: {
+ employeeId: employeeId,
+ date: date.toISOString().split('T')[0], // YYYY-MM-DD формат
+ status: data.status,
+ hoursWorked: data.hoursWorked,
+ overtimeHours: data.overtimeHours,
+ notes: data.notes
+ }
+ }
+ })
+
+ // Обновляем локальное состояние
+ const dateStr = date.toISOString().split('T')[0]
+
+ setEmployeeSchedules(prev => {
+ const currentSchedule = prev[employeeId] || []
+ const existingRecordIndex = currentSchedule.findIndex(record =>
+ record.date.split('T')[0] === dateStr
+ )
+
+ const newRecord = {
+ id: Date.now().toString(), // временный ID
+ date: date.toISOString(),
+ status: data.status,
+ hoursWorked: data.hoursWorked,
+ overtimeHours: data.overtimeHours,
+ notes: data.notes,
+ employee: { id: employeeId }
+ }
+
+ let updatedSchedule
+ if (existingRecordIndex >= 0) {
+ // Обновляем существующую запись
+ updatedSchedule = [...currentSchedule]
+ updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord }
+ } else {
+ // Добавляем новую запись
+ updatedSchedule = [...currentSchedule, newRecord]
+ }
+
+ return {
+ ...prev,
+ [employeeId]: updatedSchedule
+ }
+ })
+
+ toast.success('Данные дня обновлены')
+
+ } catch (error) {
+ console.error('Error updating day data:', error)
+ toast.error('Ошибка при обновлении данных дня')
+ }
+ }
+
const exportToCSV = () => {
const csvContent = [
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
@@ -340,25 +409,84 @@ ${employees.map((emp: Employee) =>
- {/* Заголовок страницы */}
-
setShowAddForm(!showAddForm)}
- />
+ {/* Панель управления с улучшенным расположением */}
+
+
+ {/* Красивые табы слева */}
+
+
+
+ Сотрудники
+
+
+
+ Отчеты
+
+
- {/* Поиск */}
-
+ {/* Поиск и кнопки справа */}
+
+ {/* Увеличенный поиск */}
+
+
+
+
+ {/* Кнопки управления */}
+ {showAddForm && (
+
+ )}
+
+
+
+
{/* Форма добавления сотрудника */}
{showAddForm && (
- setShowAddForm(false)}
- isLoading={createLoading}
- />
+ showCompactForm ? (
+ setShowAddForm(false)}
+ isLoading={createLoading}
+ />
+ ) : (
+ setShowAddForm(false)}
+ isLoading={createLoading}
+ />
+ )
)}
{/* Форма редактирования сотрудника */}
@@ -374,72 +502,106 @@ ${employees.map((emp: Employee) =>
/>
)}
- {/* Основной контент с вкладками */}
-
-
-
-
- Сотрудники и табель
-
-
-
- Отчеты
-
-
-
-
-
- {(() => {
- 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 (
- setShowAddForm(true)}
- />
- )
- }
+ {/* Контент табов */}
+
+
+ {(() => {
+ 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 (
-
- {/* Навигация по месяцам */}
-
+
setShowAddForm(true)}
+ />
+ )
+ }
- {/* Легенда статусов */}
+ return (
+
+ {/* Навигация по месяцам и легенда в одной строке */}
+
+
+
+ {new Date().toLocaleDateString('ru-RU', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ })}
+
+
+ {/* Кнопки навигации */}
+
+
+
+
+
+
+
+ {/* Легенда статусов справа */}
+
- {/* Объединенный список сотрудников с табелем */}
- {filteredEmployees.map((employee: Employee) => (
-
+ {/* Компактный список сотрудников с раскрывающимся табелем */}
+
+ {filteredEmployees.map((employee: Employee, index: number) => (
+
+
+
))}
- )
- })()}
-
-
+
+ )
+ })()}
+
+