Добавлены модели и функциональность для управления сотрудниками, включая создание, обновление и удаление сотрудников через GraphQL. Обновлены компоненты для отображения списка сотрудников и их расписания, улучшен интерфейс взаимодействия с пользователем. Реализованы функции экспорта отчетов в CSV и TXT форматах, добавлены новые интерфейсы и типы данных для сотрудников.

This commit is contained in:
Bivekich
2025-07-17 23:55:11 +03:00
parent 3e2a03da8c
commit d361364716
13 changed files with 3444 additions and 428 deletions

View File

@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server'
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const s3Client = new S3Client({
region: 'ru-1',
endpoint: 'https://s3.twcstorage.ru',
credentials: {
accessKeyId: 'I6XD2OR7YO2ZN6L6Z629',
secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ'
},
forcePathStyle: true
})
const BUCKET_NAME = '617774af-sfera'
// Разрешенные типы изображений
const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
'image/gif'
]
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
const documentType = formData.get('documentType') as string // 'passport' | 'other'
if (!file) {
return NextResponse.json(
{ error: 'File is required' },
{ status: 400 }
)
}
if (!documentType) {
return NextResponse.json(
{ error: 'Document type is required' },
{ status: 400 }
)
}
// Проверяем, что файл не пустой
if (file.size === 0) {
return NextResponse.json(
{ error: 'File is empty' },
{ status: 400 }
)
}
// Проверяем имя файла
if (!file.name || file.name.trim().length === 0) {
return NextResponse.json(
{ error: 'Invalid file name' },
{ status: 400 }
)
}
// Проверяем тип файла - только изображения
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: `File type ${file.type} is not allowed. Only images are supported.` },
{ status: 400 }
)
}
// Ограничиваем размер файла - 10MB для изображений
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
return NextResponse.json(
{ error: `File size must be less than 10MB` },
{ status: 400 }
)
}
// Генерируем уникальное имя файла
const timestamp = Date.now()
const safeFileName = file.name
.replace(/[^\w\s.-]/g, '_')
.replace(/\s+/g, '_')
.replace(/_{2,}/g, '_')
.toLowerCase()
// Определяем папку в зависимости от типа документа
const folder = `employee-documents/${documentType}`
const key = `${folder}/${timestamp}-${safeFileName}`
// Конвертируем файл в Buffer
const buffer = Buffer.from(await file.arrayBuffer())
// Подготавливаем метаданные
const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_')
const metadata = {
originalname: cleanOriginalName,
documenttype: documentType,
uploadtype: 'employee-document'
}
// Загружаем в S3
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: file.type,
ACL: 'public-read',
Metadata: metadata
})
await s3Client.send(command)
// Возвращаем URL файла и метаданные
const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}`
return NextResponse.json({
success: true,
url,
key,
originalName: file.name,
size: file.size,
type: file.type,
documentType
})
} catch (error) {
console.error('Error uploading employee document:', error)
let errorMessage = 'Failed to upload document'
if (error instanceof Error) {
if (error.message.includes('Invalid character in header')) {
errorMessage = 'Invalid characters in file name or metadata'
} else if (error.message.includes('AccessDenied')) {
errorMessage = 'Access denied to storage'
} else if (error.message.includes('NoSuchBucket')) {
errorMessage = 'Storage bucket not found'
} else {
errorMessage = error.message
}
}
return NextResponse.json(
{ error: errorMessage, success: false },
{ status: 500 }
)
}
}

View File

@ -1,5 +1,5 @@
import { AuthGuard } from "@/components/auth-guard"
import { EmployeesDashboard } from "@/components/employees/employees-dashboard"
import { AuthGuard } from '@/components/auth-guard'
import { EmployeesDashboard } from '@/components/employees/employees-dashboard'
export default function EmployeesPage() {
return (

View File

@ -0,0 +1,559 @@
"use client"
import { useState, useRef, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Camera,
User,
X,
Save,
UserPen,
Phone,
Mail,
Briefcase,
DollarSign,
FileText,
MessageCircle
} from 'lucide-react'
import { toast } from 'sonner'
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 EmployeeEditInlineFormProps {
employee: Employee
onSave: (employeeData: {
firstName: string
lastName: string
middleName?: string
birthDate?: string
phone: string
email?: string
position: string
salary?: number
avatar?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
}) => void
onCancel: () => void
isLoading?: boolean
}
export function EmployeeEditInlineForm({ employee, onSave, onCancel, isLoading = false }: EmployeeEditInlineFormProps) {
// Функция для форматирования даты из ISO в YYYY-MM-DD
const formatDateForInput = (dateString?: string) => {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
return date.toISOString().split('T')[0];
};
const [formData, setFormData] = useState({
firstName: employee.firstName || '',
lastName: employee.lastName || '',
middleName: employee.middleName || '',
birthDate: formatDateForInput(employee.birthDate) || '',
phone: employee.phone || '',
telegram: employee.telegram || '',
whatsapp: employee.whatsapp || '',
email: employee.email || '',
position: employee.position || '',
salary: employee.salary || 0,
avatar: employee.avatar || '',
passportPhoto: employee.passportPhoto || ''
})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
const [showPassportPreview, setShowPassportPreview] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(null)
const handleInputChange = (field: string, value: string | number) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
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('key', `avatars/employees/${Date.now()}-${file.name}`)
endpoint = '/api/upload-avatar'
} else {
formDataUpload.append('documentType', 'passport')
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()
if (!result.success) {
throw new Error(result.error || 'Неизвестная ошибка при загрузке')
}
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 formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Валидация обязательных полей
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
return
}
// Подготавливаем данные для отправки
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName || undefined,
birthDate: formData.birthDate || undefined,
phone: formData.phone,
email: formData.email || undefined,
position: formData.position,
salary: formData.salary || undefined,
avatar: formData.avatar || undefined,
telegram: formData.telegram || undefined,
whatsapp: formData.whatsapp || undefined,
passportPhoto: formData.passportPhoto || undefined
}
onSave(employeeData)
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
return `${first}${last}`
}
return (
<Card className="glass-card mb-6">
<CardHeader className="pb-4">
<CardTitle className="text-white text-xl flex items-center gap-3">
<UserPen className="h-6 w-6 text-purple-400" />
Редактировать сотрудника: {employee.firstName} {employee.lastName}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Фотографии */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Фото сотрудника */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<Camera className="h-4 w-4" />
Фото сотрудника
</Label>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20 ring-2 ring-white/20">
{formData.avatar ? (
<AvatarImage
src={formData.avatar}
alt="Фото сотрудника"
onError={(e) => {
console.error('Ошибка загрузки аватара:', formData.avatar);
e.currentTarget.style.display = 'none';
}}
onLoad={() => console.log('Аватар загружен успешно:', formData.avatar)}
/>
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => avatarInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Изменить фото'}
</Button>
{formData.avatar && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, avatar: '' }))}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-2" />
Удалить
</Button>
)}
</div>
</div>
</div>
{/* Фото паспорта */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Фото паспорта
</Label>
<div className="space-y-3">
{formData.passportPhoto ? (
<div className="relative">
<img
src={formData.passportPhoto}
alt="Паспорт"
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>
<Separator className="bg-white/10" />
{/* Основная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<User className="h-4 w-4" />
Личные данные
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Имя <span className="text-red-400">*</span>
</Label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Фамилия <span className="text-red-400">*</span>
</Label>
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Отчество</Label>
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Дата рождения
</Label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="glass-input text-white"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Контактная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Phone className="h-4 w-4" />
Контактная информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Телефон <span className="text-red-400">*</span>
</Label>
<Input
value={formData.phone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('phone', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
Telegram
</Label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
WhatsApp
</Label>
<Input
value={formData.whatsapp}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('whatsapp', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<Mail className="h-3 w-3" />
Email
</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Рабочая информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Рабочая информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Должность <span className="text-red-400">*</span>
</Label>
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<DollarSign className="h-3 w-3" />
Зарплата
</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
placeholder="80000"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300"
>
<X className="h-4 w-4 mr-2" />
Отмена
</Button>
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
{isLoading ? 'Сохранение...' : (
<>
<Save className="h-4 w-4 mr-2" />
Сохранить изменения
</>
)}
</Button>
</div>
</form>
</CardContent>
{/* Превью паспорта */}
<Dialog open={showPassportPreview} onOpenChange={setShowPassportPreview}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden glass-card">
<DialogHeader>
<DialogTitle className="text-white">Фото паспорта</DialogTitle>
</DialogHeader>
<div className="flex justify-center">
<img
src={formData.passportPhoto}
alt="Паспорт"
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@ -0,0 +1,453 @@
"use client"
import { useState, useRef } from 'react'
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 { Card } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Upload, X, User, Camera } from 'lucide-react'
import { toast } from 'sonner'
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 EmployeeFormProps {
employee?: Employee | null
onSave: (employeeData: Partial<Employee>) => void
onCancel: () => void
}
export function EmployeeForm({ employee, onSave, onCancel }: EmployeeFormProps) {
const [formData, setFormData] = useState({
firstName: employee?.firstName || '',
lastName: employee?.lastName || '',
middleName: employee?.middleName || '',
position: employee?.position || '',
phone: employee?.phone || '',
email: employee?.email || '',
avatar: employee?.avatar || '',
hireDate: employee?.hireDate || new Date().toISOString().split('T')[0],
status: employee?.status || 'ACTIVE' as const,
salary: employee?.salary || 0,
address: employee?.address || '',
birthDate: employee?.birthDate || '',
passportSeries: employee?.passportSeries || '',
passportNumber: employee?.passportNumber || '',
passportIssued: employee?.passportIssued || '',
passportDate: employee?.passportDate || '',
emergencyContact: employee?.emergencyContact || '',
emergencyPhone: employee?.emergencyPhone || ''
})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [loading, setLoading] = useState(false)
const handleInputChange = (field: string, value: string | number) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
const handleAvatarUpload = async (file: File) => {
setIsUploadingAvatar(true)
try {
const formDataUpload = new FormData()
formDataUpload.append('file', file)
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
const response = await fetch('/api/upload-avatar', {
method: 'POST',
body: formDataUpload
})
if (!response.ok) {
throw new Error('Ошибка загрузки аватара')
}
const result = await response.json()
setFormData(prev => ({
...prev,
avatar: result.url
}))
toast.success('Аватар успешно загружен')
} catch (error) {
console.error('Error uploading avatar:', error)
toast.error('Ошибка при загрузке аватара')
} finally {
setIsUploadingAvatar(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
// Валидация
if (!formData.firstName || !formData.lastName || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
setLoading(false)
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
setLoading(false)
return
}
if (formData.phone && !/^[\+]?[1-9][\d]{0,15}$/.test(formData.phone.replace(/\s/g, ''))) {
toast.error('Введите корректный номер телефона')
setLoading(false)
return
}
try {
// Для создания/обновления отправляем только нужные поля
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName,
position: formData.position,
phone: formData.phone,
email: formData.email || undefined,
avatar: formData.avatar || undefined,
hireDate: formData.hireDate,
salary: formData.salary || undefined,
address: formData.address || undefined,
birthDate: formData.birthDate || undefined,
passportSeries: formData.passportSeries || undefined,
passportNumber: formData.passportNumber || undefined,
passportIssued: formData.passportIssued || undefined,
passportDate: formData.passportDate || undefined,
emergencyContact: formData.emergencyContact || undefined,
emergencyPhone: formData.emergencyPhone || undefined
}
onSave(employeeData)
toast.success(employee ? 'Сотрудник успешно обновлен' : 'Сотрудник успешно добавлен')
} catch (error) {
console.error('Error saving employee:', error)
toast.error('Ошибка при сохранении данных сотрудника')
} finally {
setLoading(false)
}
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
return `${first}${last}`
}
const formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Фото и основная информация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Личные данные</h3>
{/* Аватар */}
<div className="flex items-start gap-6 mb-6">
<div className="flex flex-col items-center gap-3">
<Avatar className="h-24 w-24 ring-2 ring-white/20">
{formData.avatar ? (
<AvatarImage src={formData.avatar} alt="Аватар сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleAvatarUpload(e.target.files[0])}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Изменить фото'}
</Button>
</div>
<div className="flex-1 grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Имя <span className="text-red-400">*</span>
</Label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40 h-10"
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 h-10"
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 h-10"
/>
</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 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Серия паспорта</Label>
<Input
value={formData.passportSeries}
onChange={(e) => handleInputChange('passportSeries', e.target.value)}
placeholder="1234"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Номер паспорта</Label>
<Input
value={formData.passportNumber}
onChange={(e) => handleInputChange('passportNumber', e.target.value)}
placeholder="567890"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Кем выдан</Label>
<Input
value={formData.passportIssued}
onChange={(e) => handleInputChange('passportIssued', e.target.value)}
placeholder="ОУФМС России по г. Москве"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Дата выдачи</Label>
<Input
type="date"
value={formData.passportDate}
onChange={(e) => handleInputChange('passportDate', e.target.value)}
className="glass-input text-white h-10"
/>
</div>
</div>
</div>
<div className="mt-4">
<Label className="text-white/80 text-sm mb-2 block">Адрес проживания</Label>
<Input
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="Москва, ул. Ленина, 10, кв. 5"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</Card>
{/* Рабочая информация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Трудовая деятельность</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Должность <span className="text-red-400">*</span>
</Label>
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40 h-10"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Дата приема на работу</Label>
<Input
type="date"
value={formData.hireDate}
onChange={(e) => handleInputChange('hireDate', e.target.value)}
className="glass-input text-white h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Статус</Label>
<Select
value={formData.status}
onValueChange={(value: 'active' | 'vacation' | 'sick' | 'inactive') => handleInputChange('status', value)}
>
<SelectTrigger className="glass-input text-white h-10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-gray-900 border-white/20">
<SelectItem value="active" className="text-white hover:bg-white/10">Активен</SelectItem>
<SelectItem value="vacation" className="text-white hover:bg-white/10">В отпуске</SelectItem>
<SelectItem value="sick" className="text-white hover:bg-white/10">На больничном</SelectItem>
<SelectItem value="inactive" className="text-white hover:bg-white/10">Неактивен</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="mt-4">
<Label className="text-white/80 text-sm mb-2 block">Зарплата ()</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
placeholder="80000"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</Card>
{/* Контактная информация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Контактные данные</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Телефон</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 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">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 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Экстренный контакт</Label>
<Input
value={formData.emergencyContact}
onChange={(e) => handleInputChange('emergencyContact', e.target.value)}
placeholder="ФИО близкого родственника"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Телефон экстренного контакта</Label>
<Input
value={formData.emergencyPhone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('emergencyPhone', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</div>
</Card>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
>
Отмена
</Button>
<Button
type="submit"
disabled={loading || isUploadingAvatar}
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"
>
{loading ? 'Сохранение...' : (employee ? 'Сохранить изменения' : 'Добавить сотрудника')}
</Button>
</div>
</form>
)
}

View File

@ -0,0 +1,521 @@
"use client"
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Camera,
User,
X,
Save,
UserPlus,
Phone,
Mail,
Briefcase,
DollarSign,
Calendar,
FileText,
MessageCircle
} from 'lucide-react'
import { toast } from 'sonner'
interface EmployeeInlineFormProps {
onSave: (employeeData: {
firstName: string
lastName: string
middleName?: string
birthDate?: string
phone: string
email?: string
position: string
salary?: number
avatar?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
hireDate: string
}) => void
onCancel: () => void
isLoading?: boolean
}
export function EmployeeInlineForm({ onSave, onCancel, isLoading = false }: EmployeeInlineFormProps) {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
middleName: '',
birthDate: '',
phone: '',
telegram: '',
whatsapp: '',
email: '',
position: '',
salary: 0,
avatar: '',
passportPhoto: ''
})
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [isUploadingPassport, setIsUploadingPassport] = useState(false)
const [showPassportPreview, setShowPassportPreview] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const passportInputRef = useRef<HTMLInputElement>(null)
const handleInputChange = (field: string, value: string | number) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
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') {
// Для аватара используем upload-avatar API
formDataUpload.append('key', `avatars/employees/${Date.now()}-${file.name}`)
endpoint = '/api/upload-avatar'
} else {
// Для паспорта используем специальный API для документов сотрудников
formDataUpload.append('documentType', 'passport')
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()
if (!result.success) {
throw new Error(result.error || 'Неизвестная ошибка при загрузке')
}
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 formatPhoneInput = (value: string) => {
const cleaned = value.replace(/\D/g, '')
if (cleaned.length <= 1) return cleaned
if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}`
if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}`
if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}`
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Валидация обязательных полей
if (!formData.firstName || !formData.lastName || !formData.phone || !formData.position) {
toast.error('Пожалуйста, заполните все обязательные поля')
return
}
if (formData.email && !/\S+@\S+\.\S+/.test(formData.email)) {
toast.error('Введите корректный email адрес')
return
}
// Подготавливаем данные для отправки
const employeeData = {
firstName: formData.firstName,
lastName: formData.lastName,
middleName: formData.middleName || undefined,
birthDate: formData.birthDate || undefined,
phone: formData.phone,
email: formData.email || undefined,
position: formData.position,
salary: formData.salary || undefined,
avatar: formData.avatar || undefined,
telegram: formData.telegram || undefined,
whatsapp: formData.whatsapp || undefined,
passportPhoto: formData.passportPhoto || undefined,
hireDate: new Date().toISOString().split('T')[0]
}
onSave(employeeData)
}
const getInitials = () => {
const first = formData.firstName.charAt(0).toUpperCase()
const last = formData.lastName.charAt(0).toUpperCase()
return `${first}${last}`
}
return (
<Card className="glass-card mb-6">
<CardHeader className="pb-4">
<CardTitle className="text-white text-xl flex items-center gap-3">
<UserPlus className="h-6 w-6 text-purple-400" />
Добавить нового сотрудника
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Фотографии */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Фото сотрудника */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<Camera className="h-4 w-4" />
Фото сотрудника
</Label>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20 ring-2 ring-white/20">
{formData.avatar ? (
<AvatarImage src={formData.avatar} alt="Фото сотрудника" />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white text-lg font-semibold">
{getInitials() || <User className="h-8 w-8" />}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0], 'avatar')}
className="hidden"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => avatarInputRef.current?.click()}
disabled={isUploadingAvatar}
className="glass-secondary text-white hover:text-white"
>
<Camera className="h-4 w-4 mr-2" />
{isUploadingAvatar ? 'Загрузка...' : 'Загрузить фото'}
</Button>
{formData.avatar && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setFormData(prev => ({ ...prev, avatar: '' }))}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-2" />
Удалить
</Button>
)}
</div>
</div>
</div>
{/* Фото паспорта */}
<div className="space-y-3">
<Label className="text-white/80 font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Фото паспорта
</Label>
<div className="space-y-3">
{formData.passportPhoto ? (
<div className="relative">
<img
src={formData.passportPhoto}
alt="Паспорт"
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>
<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-3 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Имя <span className="text-red-400">*</span>
</Label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Александр"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Фамилия <span className="text-red-400">*</span>
</Label>
<Input
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Петров"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Отчество</Label>
<Input
value={formData.middleName}
onChange={(e) => handleInputChange('middleName', e.target.value)}
placeholder="Иванович"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Дата рождения
</Label>
<Input
type="date"
value={formData.birthDate}
onChange={(e) => handleInputChange('birthDate', e.target.value)}
className="glass-input text-white"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Контактная информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Phone className="h-4 w-4" />
Контактная информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Телефон <span className="text-red-400">*</span>
</Label>
<Input
value={formData.phone}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('phone', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
Telegram
</Label>
<Input
value={formData.telegram}
onChange={(e) => handleInputChange('telegram', e.target.value)}
placeholder="@username"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<MessageCircle className="h-3 w-3" />
WhatsApp
</Label>
<Input
value={formData.whatsapp}
onChange={(e) => {
const formatted = formatPhoneInput(e.target.value)
handleInputChange('whatsapp', formatted)
}}
placeholder="+7 (999) 123-45-67"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<Mail className="h-3 w-3" />
Email
</Label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="a.petrov@company.com"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
</div>
</div>
<Separator className="bg-white/10" />
{/* Рабочая информация */}
<div className="space-y-4">
<Label className="text-white font-medium flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Рабочая информация
</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Должность <span className="text-red-400">*</span>
</Label>
<Input
value={formData.position}
onChange={(e) => handleInputChange('position', e.target.value)}
placeholder="Менеджер склада"
className="glass-input text-white placeholder:text-white/40"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block flex items-center gap-2">
<DollarSign className="h-3 w-3" />
Зарплата
</Label>
<Input
type="number"
min="0"
value={formData.salary || ''}
onChange={(e) => handleInputChange('salary', parseInt(e.target.value) || 0)}
placeholder="80000"
className="glass-input text-white placeholder:text-white/40"
/>
</div>
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 border-red-400/30 text-red-200 hover:bg-red-500/10 hover:border-red-300 transition-all duration-300"
>
<X className="h-4 w-4 mr-2" />
Отмена
</Button>
<Button
type="submit"
disabled={isLoading || isUploadingAvatar || isUploadingPassport}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
{isLoading ? 'Сохранение...' : (
<>
<Save className="h-4 w-4 mr-2" />
Добавить сотрудника
</>
)}
</Button>
</div>
</form>
</CardContent>
{/* Превью паспорта */}
<Dialog open={showPassportPreview} onOpenChange={setShowPassportPreview}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden glass-card">
<DialogHeader>
<DialogTitle className="text-white">Фото паспорта</DialogTitle>
</DialogHeader>
<div className="flex justify-center">
<img
src={formData.passportPhoto}
alt="Паспорт"
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@ -35,15 +35,26 @@ interface EmployeeSchedule {
days: WorkDay[]
}
// Моковые данные сотрудников для календаря
const scheduleEmployees = [
{ id: '1', name: 'Александр Петров', avatar: undefined },
{ id: '2', name: 'Мария Иванова', avatar: undefined },
{ id: '3', name: 'Дмитрий Сидоров', avatar: undefined },
{ id: '4', name: 'Анна Козлова', avatar: undefined }
]
interface Employee {
id: string
firstName: string
lastName: string
position: string
department?: string
phone: string
email: string
avatar?: string
hireDate: string
status: 'active' | 'vacation' | 'sick' | 'inactive'
salary: number
address: string
}
export function EmployeeSchedule() {
interface EmployeeScheduleProps {
employees: Employee[]
}
export function EmployeeSchedule({ employees }: EmployeeScheduleProps) {
const [currentDate, setCurrentDate] = useState(new Date())
const [selectedEmployee, setSelectedEmployee] = useState<string>('all')
const [schedules, setSchedules] = useState<Record<string, EmployeeSchedule>>({})
@ -295,9 +306,9 @@ export function EmployeeSchedule() {
<SelectItem value="all" className="text-white hover:bg-white/10">
Все сотрудники
</SelectItem>
{scheduleEmployees.map(emp => (
{employees.map(emp => (
<SelectItem key={emp.id} value={emp.id} className="text-white hover:bg-white/10">
{emp.name}
{emp.firstName} {emp.lastName}
</SelectItem>
))}
</SelectContent>
@ -325,7 +336,7 @@ export function EmployeeSchedule() {
</Card>
{/* Календарь для каждого сотрудника */}
{(selectedEmployee === 'all' ? scheduleEmployees : scheduleEmployees.filter(e => e.id === selectedEmployee)).map(employee => {
{(selectedEmployee === 'all' ? employees : employees.filter(e => e.id === selectedEmployee)).map(employee => {
const stats = getMonthStats(employee.id)
return (
@ -335,14 +346,14 @@ export function EmployeeSchedule() {
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 ring-2 ring-white/20">
{employee.avatar ? (
<AvatarImage src={employee.avatar} alt={employee.name} />
<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">
{employee.name.split(' ').map(n => n.charAt(0)).join('')}
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-white font-semibold text-lg">{employee.name}</h3>
<h3 className="text-white font-semibold text-lg">{employee.firstName} {employee.lastName}</h3>
<p className="text-white/70 text-sm">Табель работы за {monthNames[currentMonth].toLowerCase()}</p>
</div>
</div>

View File

@ -1,23 +1,241 @@
"use client"
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Sidebar } from '@/components/dashboard/sidebar'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { EmployeesList } from './employees-list'
import { EmployeeSchedule } from './employee-schedule'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { EmployeeForm } from './employee-form'
import { EmployeeInlineForm } from './employee-inline-form'
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
import { toast } from 'sonner'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { GET_MY_EMPLOYEES } from '@/graphql/queries'
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
import {
Users,
Calendar,
Search,
Plus,
FileText
FileText,
Edit,
UserX,
Phone,
Mail,
Download,
BarChart3,
CheckCircle,
XCircle,
Plane,
Activity,
Clock
} from 'lucide-react'
// Интерфейс сотрудника
interface Employee {
id: string
firstName: string
lastName: string
middleName?: string
position: string
phone: string
email?: string
avatar?: string
hireDate: string
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
salary?: number
address?: string
birthDate?: string
passportSeries?: string
passportNumber?: string
passportIssued?: string
passportDate?: string
emergencyContact?: string
emergencyPhone?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
createdAt: string
updatedAt: string
}
export function EmployeesDashboard() {
const [searchQuery, setSearchQuery] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [showEditForm, setShowEditForm] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
// GraphQL запросы и мутации
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES)
const [createEmployee] = useMutation(CREATE_EMPLOYEE)
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE)
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE)
const employees = data?.myEmployees || []
const handleEditEmployee = (employee: Employee) => {
setEditingEmployee(employee)
setShowEditForm(true)
setShowAddForm(false) // Закрываем форму добавления если открыта
}
const handleEmployeeSaved = async (employeeData: Partial<Employee>) => {
try {
if (editingEmployee) {
// Обновление существующего сотрудника
const { data } = await updateEmployee({
variables: {
id: editingEmployee.id,
input: employeeData
}
})
if (data?.updateEmployee?.success) {
toast.success('Сотрудник успешно обновлен')
refetch()
}
} else {
// Добавление нового сотрудника
const { data } = await createEmployee({
variables: { input: employeeData }
})
if (data?.createEmployee?.success) {
toast.success('Сотрудник успешно добавлен')
refetch()
}
}
setShowEditForm(false)
setEditingEmployee(null)
} catch (error) {
console.error('Error saving employee:', error)
toast.error('Ошибка при сохранении сотрудника')
}
}
const handleCreateEmployee = async (employeeData: Partial<Employee>) => {
setCreateLoading(true)
try {
const { data } = await createEmployee({
variables: { input: employeeData }
})
if (data?.createEmployee?.success) {
toast.success('Сотрудник успешно добавлен!')
setShowAddForm(false)
refetch()
}
} catch (error) {
console.error('Error creating employee:', error)
toast.error('Ошибка при создании сотрудника')
} finally {
setCreateLoading(false)
}
}
const handleEmployeeDeleted = async (employeeId: string) => {
try {
setDeletingEmployeeId(employeeId)
const { data } = await deleteEmployee({
variables: { id: employeeId }
})
if (data?.deleteEmployee) {
toast.success('Сотрудник успешно уволен')
refetch()
}
} catch (error) {
console.error('Error deleting employee:', error)
toast.error('Ошибка при увольнении сотрудника')
} finally {
setDeletingEmployeeId(null)
}
}
const exportToCSV = () => {
const csvContent = [
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
...employees.map((emp: Employee) => [
`${emp.firstName} ${emp.lastName}`,
emp.position,
emp.status === 'ACTIVE' ? 'Активен' :
emp.status === 'VACATION' ? 'В отпуске' :
emp.status === 'SICK' ? 'На больничном' : 'Уволен',
emp.salary?.toString() || '',
emp.phone,
emp.email || '',
new Date(emp.hireDate).toLocaleDateString('ru-RU')
])
].map(row => row.join(',')).join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `employees_report_${new Date().toISOString().split('T')[0]}.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success('Отчет успешно экспортирован')
}
const generateReport = () => {
const stats = {
total: employees.length,
active: employees.filter((e: Employee) => e.status === 'ACTIVE').length,
vacation: employees.filter((e: Employee) => e.status === 'VACATION').length,
sick: employees.filter((e: Employee) => e.status === 'SICK').length,
inactive: employees.filter((e: Employee) => e.status === 'FIRED').length,
avgSalary: Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length)
}
const reportText = `
ОТЧЕТ ПО СОТРУДНИКАМ
Дата: ${new Date().toLocaleDateString('ru-RU')}
ОБЩАЯ СТАТИСТИКА:
Всего сотрудников: ${stats.total}
• Активных: ${stats.active}
В отпуске: ${stats.vacation}
На больничном: ${stats.sick}
• Неактивных: ${stats.inactive}
• Средняя зарплата: ${stats.avgSalary.toLocaleString('ru-RU')}
СПИСОК СОТРУДНИКОВ:
${employees.map((emp: Employee) =>
`${emp.firstName} ${emp.lastName} - ${emp.position}`
).join('\n')}
`.trim()
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `employees_summary_${new Date().toISOString().split('T')[0]}.txt`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success('Сводный отчет создан')
}
if (loading) {
return (
<div className="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-56 p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-center h-64">
<div className="text-white text-xl">Загрузка сотрудников...</div>
</div>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-smooth flex">
@ -33,10 +251,15 @@ export function EmployeesDashboard() {
<p className="text-white/70">Личные данные, табель работы и учет</p>
</div>
</div>
<Button className="glass-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>
{/* Поиск */}
@ -52,22 +275,37 @@ export function EmployeesDashboard() {
</div>
</Card>
{/* Форма добавления сотрудника */}
{showAddForm && (
<EmployeeInlineForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
)}
{/* Форма редактирования сотрудника */}
{showEditForm && editingEmployee && (
<EmployeeEditInlineForm
employee={editingEmployee}
onSave={handleEmployeeSaved}
onCancel={() => {
setShowEditForm(false)
setEditingEmployee(null)
}}
isLoading={createLoading}
/>
)}
{/* Основной контент с вкладками */}
<Tabs defaultValue="list" className="w-full">
<TabsList className="glass-card mb-6 grid w-full grid-cols-3">
<Tabs defaultValue="combined" className="w-full">
<TabsList className="glass-card mb-6 grid w-full grid-cols-2">
<TabsTrigger
value="list"
value="combined"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Users className="h-4 w-4 mr-2" />
Список сотрудников
</TabsTrigger>
<TabsTrigger
value="schedule"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Calendar className="h-4 w-4 mr-2" />
Табель работы
Сотрудники и табель
</TabsTrigger>
<TabsTrigger
value="reports"
@ -78,25 +316,548 @@ export function EmployeesDashboard() {
</TabsTrigger>
</TabsList>
<TabsContent value="list">
<EmployeesList searchQuery={searchQuery} />
</TabsContent>
<TabsContent value="combined">
<Card className="glass-card p-6">
{(() => {
const filteredEmployees = employees.filter(employee =>
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.position.toLowerCase().includes(searchQuery.toLowerCase()) ||
(employee.department && employee.department.toLowerCase().includes(searchQuery.toLowerCase()))
)
<TabsContent value="schedule">
<EmployeeSchedule />
if (filteredEmployees.length === 0) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="h-8 w-8 text-white/40" />
</div>
<h3 className="text-lg font-medium text-white mb-2">
{searchQuery ? 'Сотрудники не найдены' : 'У вас пока нет сотрудников'}
</h3>
<p className="text-white/60 text-sm mb-4">
{searchQuery
? 'Попробуйте изменить критерии поиска'
: 'Добавьте первого сотрудника в вашу команду'
}
</p>
{!searchQuery && (
<Button
onClick={() => setShowAddForm(true)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
Добавить сотрудника
</Button>
)}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Навигация по месяцам */}
<div className="flex items-center justify-between mb-6">
<h3 className="text-white font-medium text-lg">Январь 2024</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
Декабрь
</Button>
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
Сегодня
</Button>
<Button variant="outline" size="sm" className="glass-secondary text-white hover:text-white">
Февраль
</Button>
</div>
</div>
{/* Легенда точно как в гите */}
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-green-500/20 text-green-300 border-green-500/30">
<CheckCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Рабочий день</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-gray-500/20 text-gray-300 border-gray-500/30">
<Clock className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Выходной</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-blue-500/20 text-blue-300 border-blue-500/30">
<Plane className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Отпуск</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
<Activity className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Больничный</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-red-500/20 text-red-300 border-red-500/30">
<XCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Прогул</span>
</div>
</div>
{/* Объединенный список сотрудников с табелем */}
{filteredEmployees.map((employee) => {
// Генерируем календарные дни для текущего месяца
const currentDate = new Date()
const currentMonth = currentDate.getMonth()
const currentYear = currentDate.getFullYear()
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate()
// Создаем массив дней с моковыми данными табеля
const generateDayStatus = (day: number) => {
const date = new Date(currentYear, currentMonth, day)
const dayOfWeek = date.getDay()
// Выходные
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
// Некоторые случайные отпуска/больничные для демонстрации
if ([15, 16].includes(day)) return 'vacation'
if ([10].includes(day)) return 'sick'
if ([22].includes(day)) return 'absent'
return 'work'
}
const getDayClass = (status: string) => {
switch (status) {
case 'work': return 'bg-green-500/30 border-green-500/50'
case 'weekend': return 'bg-gray-500/30 border-gray-500/50'
case 'vacation': return 'bg-blue-500/30 border-blue-500/50'
case 'sick': return 'bg-yellow-500/30 border-yellow-500/50'
case 'absent': return 'bg-red-500/30 border-red-500/50'
default: return 'bg-white/10 border-white/20'
}
}
// Подсчитываем статистику
const stats = {
workDays: 0,
vacationDays: 0,
sickDays: 0,
absentDays: 0,
totalHours: 0
}
for (let day = 1; day <= daysInMonth; day++) {
const status = generateDayStatus(day)
switch (status) {
case 'work':
stats.workDays++
stats.totalHours += 8
break
case 'vacation':
stats.vacationDays++
break
case 'sick':
stats.sickDays++
break
case 'absent':
stats.absentDays++
break
}
}
return (
<Card key={employee.id} className="glass-card p-6">
<div className="flex flex-col lg:flex-row gap-6">
{/* Информация о сотруднике */}
<div className="lg:w-80 flex-shrink-0">
<div className="flex items-start space-x-4 mb-4">
<Avatar className="h-16 w-16 ring-2 ring-white/20">
{employee.avatar ? (
<AvatarImage
src={employee.avatar}
alt={`${employee.firstName} ${employee.lastName}`}
onError={(e) => {
console.error('Ошибка загрузки аватара:', employee.avatar);
e.currentTarget.style.display = 'none';
}}
onLoad={() => console.log('Аватар загружен успешно:', employee.avatar)}
/>
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold text-lg">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-semibold text-lg truncate">
{employee.firstName} {employee.lastName}
</h3>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => handleEditEmployee(employee)}
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<UserX className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="glass-card border-white/10">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
<AlertDialogDescription className="text-white/70">
Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}?
Это действие нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleEmployeeDeleted(employee.id)}
disabled={deletingEmployeeId === employee.id}
className="bg-red-600 hover:bg-red-700 text-white"
>
{deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<p className="text-purple-300 font-medium mb-1">{employee.position}</p>
{employee.department && (
<p className="text-white/60 text-sm mb-3">{employee.department}</p>
)}
<div className="space-y-2">
<div className="flex items-center text-white/70 text-sm">
<Phone className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.phone}</span>
</div>
{employee.email && (
<div className="flex items-center text-white/70 text-sm">
<Mail className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{employee.email}</span>
</div>
)}
</div>
</div>
</div>
{/* Статистика за месяц */}
<div className="grid grid-cols-2 gap-3">
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-green-400 font-semibold text-lg">{stats.workDays}</p>
<p className="text-white/60 text-xs">Рабочих дней</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-blue-400 font-semibold text-lg">{stats.vacationDays}</p>
<p className="text-white/60 text-xs">Отпуск</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-yellow-400 font-semibold text-lg">{stats.sickDays}</p>
<p className="text-white/60 text-xs">Больничный</p>
</div>
<div className="text-center p-3 bg-white/5 rounded-lg">
<p className="text-white font-semibold text-lg">{stats.totalHours}ч</p>
<p className="text-white/60 text-xs">Всего часов</p>
</div>
</div>
</div>
{/* Табель работы 1 в 1 как в гите */}
<div className="flex-1">
<h4 className="text-white/80 font-medium mb-3 flex items-center gap-2">
<Calendar className="h-4 w-4" />
Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })}
</h4>
{/* Сетка календаря - точно как в гите */}
<div className="grid grid-cols-7 gap-2">
{/* Заголовки дней недели */}
{['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => (
<div key={day} className="p-2 text-center text-white/70 font-medium text-sm">
{day}
</div>
))}
{/* Дни месяца */}
{(() => {
// Точная логика из гита
const calendarDays: (number | null)[] = []
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay()
const startOffset = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1
// Добавляем пустые ячейки для выравнивания первой недели
for (let i = 0; i < startOffset; i++) {
calendarDays.push(null)
}
// Добавляем дни месяца
for (let day = 1; day <= daysInMonth; day++) {
calendarDays.push(day)
}
// Функции статуса и стилей точно как в гите
const getDayStatus = (day: number) => {
const date = new Date(currentYear, currentMonth, day)
const dayOfWeek = date.getDay()
if (dayOfWeek === 0 || dayOfWeek === 6) return 'weekend'
if ([15, 16].includes(day)) return 'vacation'
if ([10].includes(day)) return 'sick'
if ([22].includes(day)) return 'absent'
return 'work'
}
const getCellStyle = (status: string) => {
switch (status) {
case 'work':
return 'bg-green-500/20 text-green-300 border-green-500/30'
case 'weekend':
return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
case 'vacation':
return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
case 'sick':
return 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30'
case 'absent':
return 'bg-red-500/20 text-red-300 border-red-500/30'
default:
return 'bg-white/10 text-white/70 border-white/20'
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'work':
return <CheckCircle className="h-3 w-3" />
case 'weekend':
return <Clock className="h-3 w-3" />
case 'vacation':
return <Plane className="h-3 w-3" />
case 'sick':
return <Activity className="h-3 w-3" />
case 'absent':
return <XCircle className="h-3 w-3" />
default:
return null
}
}
return calendarDays.map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="p-2"></div>
}
const status = getDayStatus(day)
const hours = status === 'work' ? 8 : status === 'vacation' || status === 'sick' ? 8 : 0
const isToday = new Date().getDate() === day &&
new Date().getMonth() === currentMonth &&
new Date().getFullYear() === currentYear
return (
<div
key={`${employee.id}-${day}`}
className={`
relative p-2 min-h-[60px] border rounded-lg cursor-pointer
transition-all duration-200 hover:scale-105
${getCellStyle(status)}
${isToday ? 'ring-2 ring-white/50' : ''}
`}
onClick={() => {
// Циклично переключаем статусы - точно как в гите
// const statuses = ['work', 'weekend', 'vacation', 'sick', 'absent']
// const currentIndex = statuses.indexOf(status)
// const nextStatus = statuses[(currentIndex + 1) % statuses.length]
// changeDayStatus(employee.id, day, nextStatus)
}}
>
<div className="flex flex-col items-center justify-center h-full">
<div className="flex items-center gap-1 mb-1">
{getStatusIcon(status)}
<span className="font-semibold text-sm">{day}</span>
</div>
{hours > 0 && (
<span className="text-xs opacity-80">{hours}ч</span>
)}
</div>
{isToday && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
)}
</div>
)
})
})()}
</div>
</div>
</div>
</Card>
)
})}
</div>
)
})()}
</Card>
</TabsContent>
<TabsContent value="reports">
<Card className="glass-card p-8">
<div className="text-center">
<FileText className="h-16 w-16 text-white/40 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">Отчеты</h3>
<p className="text-white/70 mb-4">
Генерация отчетов по работе сотрудников
</p>
<p className="text-white/50 text-sm">Функция в разработке</p>
{employees.length === 0 ? (
<Card className="glass-card p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<BarChart3 className="h-8 w-8 text-white/40" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Нет данных для отчетов</h3>
<p className="text-white/60 text-sm mb-4">
Добавьте сотрудников, чтобы генерировать отчеты и аналитику
</p>
<Button
onClick={() => setShowAddForm(true)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
Добавить сотрудника
</Button>
</div>
</div>
</Card>
) : (
<div className="space-y-6">
{/* Статистические карточки */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Всего сотрудников</p>
<p className="text-2xl font-bold text-white">{employees.length}</p>
</div>
<Users className="h-8 w-8 text-purple-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Активных</p>
<p className="text-2xl font-bold text-green-400">
{employees.filter((e: Employee) => e.status === 'ACTIVE').length}
</p>
</div>
<BarChart3 className="h-8 w-8 text-green-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Средняя зарплата</p>
<p className="text-2xl font-bold text-blue-400">
{employees.length > 0
? Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length).toLocaleString('ru-RU')
: '0'}
</p>
</div>
<FileText className="h-8 w-8 text-blue-400" />
</div>
</Card>
<Card className="glass-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm">Отделов</p>
<p className="text-2xl font-bold text-orange-400">
{new Set(employees.map((e: Employee) => e.department).filter(Boolean)).size}
</p>
</div>
<Calendar className="h-8 w-8 text-orange-400" />
</div>
</Card>
</div>
{/* Экспорт отчетов */}
<Card className="glass-card p-6">
<h3 className="text-white font-medium mb-4 flex items-center gap-2">
<Download className="h-5 w-5" />
Экспорт отчетов
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<h4 className="text-white/80 font-medium">Детальный отчет (CSV)</h4>
<p className="text-white/60 text-sm">
Полная информация о всех сотрудниках в формате таблицы для Excel/Google Sheets
</p>
<Button
onClick={exportToCSV}
className="w-full glass-button"
>
<Download className="h-4 w-4 mr-2" />
Скачать CSV
</Button>
</div>
<div className="space-y-3">
<h4 className="text-white/80 font-medium">Сводный отчет (TXT)</h4>
<p className="text-white/60 text-sm">
Краткая статистика и список сотрудников в текстовом формате
</p>
<Button
onClick={generateReport}
className="w-full glass-button"
>
<BarChart3 className="h-4 w-4 mr-2" />
Создать отчет
</Button>
</div>
</div>
</Card>
{/* Аналитика по отделам */}
<Card className="glass-card p-6">
<h3 className="text-white font-medium mb-4">Распределение по отделам</h3>
<div className="space-y-3">
{Array.from(new Set(employees.map(e => e.department).filter(Boolean))).map(dept => {
const deptEmployees = employees.filter(e => e.department === dept)
const percentage = Math.round((deptEmployees.length / employees.length) * 100)
return (
<div key={dept} className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-white/80 text-sm">{dept}</span>
<span className="text-white/60 text-xs">{deptEmployees.length} чел. ({percentage}%)</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2">
<div
className="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full"
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
</div>
)
})}
</div>
</Card>
</div>
</Card>
)}
</TabsContent>
</Tabs>
</div>

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@ -18,7 +19,9 @@ import {
User,
Briefcase,
Save,
X
X,
Trash2,
UserX
} from 'lucide-react'
// Интерфейс сотрудника
@ -27,7 +30,7 @@ interface Employee {
firstName: string
lastName: string
position: string
department: string
department?: string
phone: string
email: string
avatar?: string
@ -95,10 +98,12 @@ const mockEmployees: Employee[] = [
interface EmployeesListProps {
searchQuery: string
employees: Employee[]
onEditEmployee: (employee: Employee) => void
onDeleteEmployee: (employeeId: string) => void
}
export function EmployeesList({ searchQuery }: EmployeesListProps) {
const [employees, setEmployees] = useState<Employee[]>(mockEmployees)
export function EmployeesList({ searchQuery, employees, onEditEmployee, onDeleteEmployee }: EmployeesListProps) {
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
@ -106,7 +111,7 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
const filteredEmployees = employees.filter(employee =>
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.position.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.department.toLowerCase().includes(searchQuery.toLowerCase())
(employee.department && employee.department.toLowerCase().includes(searchQuery.toLowerCase()))
)
const getStatusBadge = (status: Employee['status']) => {
@ -133,18 +138,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
}
const handleEditEmployee = (employee: Employee) => {
setSelectedEmployee(employee)
setIsEditModalOpen(true)
onEditEmployee(employee)
}
const handleSaveEmployee = () => {
if (selectedEmployee) {
setEmployees(prev =>
prev.map(emp => emp.id === selectedEmployee.id ? selectedEmployee : emp)
)
setIsEditModalOpen(false)
setSelectedEmployee(null)
}
const handleDeleteEmployee = (employee: Employee) => {
onDeleteEmployee(employee.id)
}
return (
@ -214,14 +212,48 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
<h3 className="text-white font-semibold text-lg truncate">
{employee.firstName} {employee.lastName}
</h3>
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => handleEditEmployee(employee)}
>
<Edit className="h-4 w-4" />
</Button>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => handleEditEmployee(employee)}
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-red-400/60 hover:text-red-300 hover:bg-red-500/10 h-8 w-8 p-0"
>
<UserX className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="glass-card border-white/10">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Уволить сотрудника?</AlertDialogTitle>
<AlertDialogDescription className="text-white/70">
Вы уверены, что хотите уволить {employee.firstName} {employee.lastName}?
Это действие изменит статус сотрудника на &quot;Неактивен&quot; и удалит его из активного списка.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="glass-secondary text-white hover:text-white">
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteEmployee(employee)}
className="bg-red-600 hover:bg-red-700 text-white"
>
Уволить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<p className="text-purple-300 font-medium mb-1">{employee.position}</p>
@ -389,11 +421,11 @@ export function EmployeesList({ searchQuery }: EmployeesListProps) {
<div className="flex gap-2 pt-4">
<Button
onClick={handleSaveEmployee}
onClick={() => setIsEditModalOpen(false)}
className="glass-button flex-1"
>
<Save className="h-4 w-4 mr-2" />
Сохранить
Закрыть
</Button>
<Button
variant="outline"

View File

@ -874,4 +874,78 @@ export const REMOVE_FROM_FAVORITES = gql`
}
}
}
`
// Мутации для сотрудников
export const CREATE_EMPLOYEE = gql`
mutation CreateEmployee($input: CreateEmployeeInput!) {
createEmployee(input: $input) {
success
message
employee {
id
firstName
lastName
middleName
birthDate
avatar
position
department
hireDate
salary
status
phone
email
emergencyContact
emergencyPhone
createdAt
updatedAt
}
}
}
`
export const UPDATE_EMPLOYEE = gql`
mutation UpdateEmployee($id: ID!, $input: UpdateEmployeeInput!) {
updateEmployee(id: $id, input: $input) {
success
message
employee {
id
firstName
lastName
middleName
birthDate
avatar
passportSeries
passportNumber
passportIssued
passportDate
address
position
department
hireDate
salary
status
phone
email
emergencyContact
emergencyPhone
createdAt
updatedAt
}
}
}
`
export const DELETE_EMPLOYEE = gql`
mutation DeleteEmployee($id: ID!) {
deleteEmployee(id: $id)
}
`
export const UPDATE_EMPLOYEE_SCHEDULE = gql`
mutation UpdateEmployeeSchedule($input: UpdateScheduleInput!) {
updateEmployeeSchedule(input: $input)
}
`

View File

@ -476,4 +476,66 @@ export const GET_MY_FAVORITES = gql`
}
}
}
`
// Запросы для сотрудников
export const GET_MY_EMPLOYEES = gql`
query GetMyEmployees {
myEmployees {
id
firstName
lastName
middleName
birthDate
avatar
passportSeries
passportNumber
passportIssued
passportDate
address
position
department
hireDate
salary
status
phone
email
telegram
whatsapp
passportPhoto
emergencyContact
emergencyPhone
createdAt
updatedAt
}
}
`
export const GET_EMPLOYEE = gql`
query GetEmployee($id: ID!) {
employee(id: $id) {
id
firstName
lastName
middleName
birthDate
avatar
passportSeries
passportNumber
passportIssued
passportDate
address
position
department
hireDate
salary
status
phone
email
emergencyContact
emergencyPhone
createdAt
updatedAt
}
}
`

View File

@ -676,6 +676,72 @@ export const resolvers = {
})
return favorites.map(favorite => favorite.product)
},
// Сотрудники организации
myEmployees: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
const employees = await prisma.employee.findMany({
where: { organizationId: currentUser.organization.id },
include: {
organization: true
},
orderBy: { createdAt: 'desc' }
})
return employees
},
// Получение сотрудника по ID
employee: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
const employee = await prisma.employee.findFirst({
where: {
id: args.id,
organizationId: currentUser.organization.id
},
include: {
organization: true
}
})
return employee
}
},
@ -3041,6 +3107,206 @@ export const resolvers = {
message: 'Ошибка при удалении из избранного'
}
}
},
// Создать сотрудника
createEmployee: async (_: unknown, args: { input: any }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
try {
const employee = await prisma.employee.create({
data: {
...args.input,
organizationId: currentUser.organization.id,
birthDate: args.input.birthDate ? new Date(args.input.birthDate) : undefined,
passportDate: args.input.passportDate ? new Date(args.input.passportDate) : undefined,
hireDate: new Date(args.input.hireDate)
},
include: {
organization: true
}
})
return {
success: true,
message: 'Сотрудник успешно добавлен',
employee
}
} catch (error) {
console.error('Error creating employee:', error)
return {
success: false,
message: 'Ошибка при создании сотрудника'
}
}
},
// Обновить сотрудника
updateEmployee: async (_: unknown, args: { id: string; input: any }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
try {
const employee = await prisma.employee.update({
where: {
id: args.id,
organizationId: currentUser.organization.id
},
data: {
...args.input,
birthDate: args.input.birthDate ? new Date(args.input.birthDate) : undefined,
passportDate: args.input.passportDate ? new Date(args.input.passportDate) : undefined,
hireDate: args.input.hireDate ? new Date(args.input.hireDate) : undefined
},
include: {
organization: true
}
})
return {
success: true,
message: 'Сотрудник успешно обновлен',
employee
}
} catch (error) {
console.error('Error updating employee:', error)
return {
success: false,
message: 'Ошибка при обновлении сотрудника'
}
}
},
// Удалить сотрудника
deleteEmployee: async (_: unknown, args: { id: string }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
try {
await prisma.employee.delete({
where: {
id: args.id,
organizationId: currentUser.organization.id
}
})
return true
} catch (error) {
console.error('Error deleting employee:', error)
return false
}
},
// Обновить табель сотрудника
updateEmployeeSchedule: async (_: unknown, args: { input: any }, context: Context) => {
if (!context.user) {
throw new GraphQLError('Требуется авторизация', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true }
})
if (!currentUser?.organization) {
throw new GraphQLError('У пользователя нет организации')
}
if (currentUser.organization.type !== 'FULFILLMENT') {
throw new GraphQLError('Доступно только для фулфилмент центров')
}
try {
// Проверяем что сотрудник принадлежит организации
const employee = await prisma.employee.findFirst({
where: {
id: args.input.employeeId,
organizationId: currentUser.organization.id
}
})
if (!employee) {
throw new GraphQLError('Сотрудник не найден')
}
// Создаем или обновляем запись табеля
await prisma.employeeSchedule.upsert({
where: {
employeeId_date: {
employeeId: args.input.employeeId,
date: new Date(args.input.date)
}
},
create: {
employeeId: args.input.employeeId,
date: new Date(args.input.date),
status: args.input.status,
hoursWorked: args.input.hoursWorked,
notes: args.input.notes
},
update: {
status: args.input.status,
hoursWorked: args.input.hoursWorked,
notes: args.input.notes
}
})
return true
} catch (error) {
console.error('Error updating employee schedule:', error)
return false
}
}
},
@ -3120,5 +3386,61 @@ export const resolvers = {
}
return parent.updatedAt
}
},
Employee: {
birthDate: (parent: { birthDate?: Date | string | null }) => {
if (!parent.birthDate) return null
if (parent.birthDate instanceof Date) {
return parent.birthDate.toISOString()
}
return parent.birthDate
},
passportDate: (parent: { passportDate?: Date | string | null }) => {
if (!parent.passportDate) return null
if (parent.passportDate instanceof Date) {
return parent.passportDate.toISOString()
}
return parent.passportDate
},
hireDate: (parent: { hireDate: Date | string }) => {
if (parent.hireDate instanceof Date) {
return parent.hireDate.toISOString()
}
return parent.hireDate
},
createdAt: (parent: { createdAt: Date | string }) => {
if (parent.createdAt instanceof Date) {
return parent.createdAt.toISOString()
}
return parent.createdAt
},
updatedAt: (parent: { updatedAt: Date | string }) => {
if (parent.updatedAt instanceof Date) {
return parent.updatedAt.toISOString()
}
return parent.updatedAt
}
},
EmployeeSchedule: {
date: (parent: { date: Date | string }) => {
if (parent.date instanceof Date) {
return parent.date.toISOString()
}
return parent.date
},
createdAt: (parent: { createdAt: Date | string }) => {
if (parent.createdAt instanceof Date) {
return parent.createdAt.toISOString()
}
return parent.createdAt
},
updatedAt: (parent: { updatedAt: Date | string }) => {
if (parent.updatedAt instanceof Date) {
return parent.updatedAt.toISOString()
}
return parent.updatedAt
}
}
}

View File

@ -43,6 +43,10 @@ export const typeDefs = gql`
# Избранные товары пользователя
myFavorites: [Product!]!
# Сотрудники организации
myEmployees: [Employee!]!
employee(id: ID!): Employee
}
type Mutation {
@ -107,6 +111,12 @@ export const typeDefs = gql`
# Работа с избранным
addToFavorites(productId: ID!): FavoritesResponse!
removeFromFavorites(productId: ID!): FavoritesResponse!
# Работа с сотрудниками
createEmployee(input: CreateEmployeeInput!): EmployeeResponse!
updateEmployee(id: ID!, input: UpdateEmployeeInput!): EmployeeResponse!
deleteEmployee(id: ID!): Boolean!
updateEmployeeSchedule(input: UpdateScheduleInput!): Boolean!
}
# Типы данных
@ -470,6 +480,132 @@ export const typeDefs = gql`
favorites: [Product!]
}
# Типы для сотрудников
type Employee {
id: ID!
firstName: String!
lastName: String!
middleName: String
birthDate: String
avatar: String
passportPhoto: String
passportSeries: String
passportNumber: String
passportIssued: String
passportDate: String
address: String
position: String!
department: String
hireDate: String!
salary: Float
status: EmployeeStatus!
phone: String!
email: String
telegram: String
whatsapp: String
emergencyContact: String
emergencyPhone: String
scheduleRecords: [EmployeeSchedule!]!
organization: Organization!
createdAt: String!
updatedAt: String!
}
enum EmployeeStatus {
ACTIVE
VACATION
SICK
FIRED
}
type EmployeeSchedule {
id: ID!
date: String!
status: ScheduleStatus!
hoursWorked: Float
notes: String
employee: Employee!
createdAt: String!
updatedAt: String!
}
enum ScheduleStatus {
WORK
WEEKEND
VACATION
SICK
ABSENT
}
input CreateEmployeeInput {
firstName: String!
lastName: String!
middleName: String
birthDate: String
avatar: String
passportPhoto: String
passportSeries: String
passportNumber: String
passportIssued: String
passportDate: String
address: String
position: String!
department: String
hireDate: String!
salary: Float
phone: String!
email: String
telegram: String
whatsapp: String
emergencyContact: String
emergencyPhone: String
}
input UpdateEmployeeInput {
firstName: String
lastName: String
middleName: String
birthDate: String
avatar: String
passportPhoto: String
passportSeries: String
passportNumber: String
passportIssued: String
passportDate: String
address: String
position: String
department: String
hireDate: String
salary: Float
status: EmployeeStatus
phone: String
email: String
telegram: String
whatsapp: String
emergencyContact: String
emergencyPhone: String
}
input UpdateScheduleInput {
employeeId: ID!
date: String!
status: ScheduleStatus!
hoursWorked: Float
notes: String
}
type EmployeeResponse {
success: Boolean!
message: String!
employee: Employee
}
type EmployeesResponse {
success: Boolean!
message: String!
employees: [Employee!]!
}
# JSON скаляр
scalar JSON
`