Удален резервный файл employees-dashboard.tsx и добавлены новые функции для проверки уникальности артикула в форме продукта. Обновлены мутации GraphQL для поддержки проверки уникальности артикула, а также добавлены уведомления о низких остатках на складе. Оптимизирован интерфейс для улучшения пользовательского опыта.

This commit is contained in:
Bivekich
2025-08-01 12:10:48 +03:00
parent 52881cf302
commit 50b02f97b7
7 changed files with 566 additions and 804 deletions

View File

@ -1,797 +0,0 @@
"use client"
import { useState, useEffect, useMemo } from 'react'
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 { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
// 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, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
import {
Users,
Calendar,
Search,
Plus,
FileText,
Edit,
UserX,
Phone,
Mail,
Download,
BarChart3,
CheckCircle,
XCircle,
Plane,
Activity,
Clock,
Briefcase,
MapPin,
AlertCircle,
MessageCircle,
ChevronDown,
ChevronUp
} 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)
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
const [currentYear] = useState(new Date().getFullYear())
const [currentMonth] = useState(new Date().getMonth())
const [expandedEmployees, setExpandedEmployees] = useState<Set<string>>(new Set())
interface ScheduleRecord {
id: string
date: string
status: string
hoursWorked?: number
employee: {
id: string
}
}
// 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 [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE)
const employees = useMemo(() => data?.myEmployees || [], [data?.myEmployees])
// Загружаем данные табеля для всех сотрудников
useEffect(() => {
const loadScheduleData = async () => {
if (employees.length > 0) {
const schedulePromises = employees.map(async (employee: Employee) => {
try {
const { data } = await apolloClient.query({
query: GET_EMPLOYEE_SCHEDULE,
variables: {
employeeId: employee.id,
year: currentYear,
month: currentMonth
}
})
return { employeeId: employee.id, scheduleData: data?.employeeSchedule || [] }
} catch (error) {
console.error(`Error loading schedule for ${employee.id}:`, error)
return { employeeId: employee.id, scheduleData: [] }
}
})
const results = await Promise.all(schedulePromises)
const scheduleMap: {[key: string]: ScheduleRecord[]} = {}
results.forEach((result: { employeeId: string; scheduleData: ScheduleRecord[] }) => {
if (result && result.scheduleData) {
scheduleMap[result.employeeId] = result.scheduleData
}
})
setEmployeeSchedules(scheduleMap)
}
}
loadScheduleData()
}, [employees, currentYear, currentMonth])
const handleEditEmployee = (employee: Employee) => {
setEditingEmployee(employee)
setShowEditForm(true)
setShowAddForm(false) // Закрываем форму добавления если открыта
}
const toggleEmployeeExpansion = (employeeId: string) => {
setExpandedEmployees(prev => {
const newSet = new Set(prev)
if (newSet.has(employeeId)) {
newSet.delete(employeeId)
} else {
newSet.add(employeeId)
}
return newSet
})
}
const handleEmployeeSaved = async (employeeData: Partial<Employee>) => {
try {
const { data: updatedData } = await updateEmployee({
variables: {
id: editingEmployee!.id,
input: employeeData
}
})
if (updatedData?.updateEmployee) {
toast.success('Сотрудник успешно обновлен')
await refetch()
setShowEditForm(false)
setEditingEmployee(null)
}
} catch (error) {
console.error('Error updating employee:', error)
toast.error('Ошибка при обновлении сотрудника')
}
}
const handleCreateEmployee = async (employeeData: Partial<Employee>) => {
setCreateLoading(true)
try {
const { data: createdData } = await createEmployee({
variables: {
input: employeeData
}
})
if (createdData?.createEmployee) {
toast.success('Сотрудник успешно добавлен')
await refetch()
setShowAddForm(false)
}
} catch (error) {
console.error('Error creating employee:', error)
toast.error('Ошибка при создании сотрудника')
}
setCreateLoading(false)
}
const handleEmployeeDeleted = async (employeeId: string) => {
setDeletingEmployeeId(employeeId)
try {
await deleteEmployee({
variables: { id: employeeId }
})
toast.success('Сотрудник уволен')
await refetch()
} catch (error) {
console.error('Error deleting employee:', error)
toast.error('Ошибка при увольнении сотрудника')
}
setDeletingEmployeeId(null)
}
// Функция для изменения статуса дня в табеле
const changeDayStatus = (employeeId: string, day: number, currentStatus: string) => {
// Циклично переключаем статусы
const statusCycle = ['work', 'weekend', 'vacation', 'sick', 'absent']
const currentIndex = statusCycle.indexOf(currentStatus)
const nextStatus = statusCycle[(currentIndex + 1) % statusCycle.length]
// TODO: Реализовать сохранение в базу данных через GraphQL мутацию
console.log(`Changing status for employee ${employeeId}, day ${day} from ${currentStatus} to ${nextStatus}`)
}
// Функция для генерации отчета
const generateReport = () => {
const reportData = employees.map((employee: Employee) => ({
name: `${employee.firstName} ${employee.lastName}`,
position: employee.position,
phone: employee.phone,
email: employee.email || 'Не указан',
hireDate: new Date(employee.hireDate).toLocaleDateString('ru-RU'),
status: employee.status === 'ACTIVE' ? 'Активен' :
employee.status === 'VACATION' ? 'В отпуске' :
employee.status === 'SICK' ? 'На больничном' : 'Уволен'
}))
// Создаем CSV контент
const csvHeaders = ['Имя', 'Должность', 'Телефон', 'Email', 'Дата приема', 'Статус']
const csvRows = reportData.map(emp => [
emp.name,
emp.position,
emp.phone,
emp.email,
emp.hireDate,
emp.status
])
const csvContent = [
csvHeaders.join(','),
...csvRows.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`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success('Сводный отчет создан')
}
if (loading) {
return (
<div className="min-h-screen 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 flex">
<Sidebar />
<main className="flex-1 ml-56 p-6">
<div className="max-w-7xl mx-auto">
{/* Заголовок страницы */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Users className="h-8 w-8 text-purple-400" />
<div>
<h1 className="text-2xl font-bold text-white">Управление сотрудниками</h1>
<p className="text-white/70">Личные данные, табель работы и учет</p>
</div>
</div>
<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>
{/* Поиск */}
<Card className="glass-card p-4 mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/60 h-4 w-4" />
<Input
placeholder="Поиск сотрудников..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="glass-input pl-10"
/>
</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="combined" className="w-full">
<TabsList className="glass-card mb-6 grid w-full grid-cols-2">
<TabsTrigger
value="combined"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<Users className="h-4 w-4 mr-2" />
Сотрудники и табель
</TabsTrigger>
<TabsTrigger
value="reports"
className="text-white data-[state=active]:bg-white/20 cursor-pointer"
>
<FileText className="h-4 w-4 mr-2" />
Отчеты
</TabsTrigger>
</TabsList>
<TabsContent value="combined">
<Card className="glass-card p-6">
{(() => {
const filteredEmployees = employees.filter((employee: Employee) =>
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.position.toLowerCase().includes(searchQuery.toLowerCase())
)
if (filteredEmployees.length === 0) {
return (
<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 flex-wrap items-center gap-6 p-4 bg-white/5 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border bg-emerald-500/20 text-emerald-300/70 border-emerald-400/80 flex items-center justify-center">
<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-purple-500/20 text-purple-300/70 border-purple-400/80 flex items-center justify-center">
<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/70 border-blue-400/80 flex items-center justify-center">
<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/70 border-yellow-400/80 flex items-center justify-center">
<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/70 border-red-400/80 flex items-center justify-center">
<XCircle className="h-3 w-3" />
</div>
<span className="text-white/70 text-sm">Прогул</span>
</div>
</div>
{/* Строчный список сотрудников с сворачиваемым табелем */}
<div className="space-y-3">
{filteredEmployees.map((employee: Employee) => {
const isExpanded = expandedEmployees.has(employee.id)
// Генерируем календарные дни для текущего месяца
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 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 (
<div key={employee.id} className="glass-card border border-white/10 rounded-lg overflow-hidden">
{/* Строчка сотрудника */}
<div className="flex items-center justify-between p-4 hover:bg-white/5 transition-colors">
{/* Левая часть - аватар и основная информация */}
<div className="flex items-center gap-4 flex-1">
<Avatar className="h-12 w-12 ring-2 ring-white/20">
{employee.avatar ? (
<AvatarImage src={employee.avatar} alt={`${employee.firstName} ${employee.lastName}`} />
) : null}
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-purple-600 text-white font-semibold">
{employee.firstName.charAt(0)}{employee.lastName.charAt(0)}
</AvatarFallback>
</Avatar>
{/* Основная информация */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-white font-semibold truncate">
{employee.firstName} {employee.lastName}
</h3>
<span className="text-purple-300 text-sm">{employee.position}</span>
</div>
<div className="flex items-center gap-4 text-sm text-white/70">
<span className="flex items-center gap-1">
<Phone className="h-3 w-3" />
{employee.phone}
</span>
{employee.email && (
<span className="flex items-center gap-1">
<Mail className="h-3 w-3" />
{employee.email}
</span>
)}
<span className="flex items-center gap-1">
<Briefcase className="h-3 w-3" />
Принят: {new Date(employee.hireDate).toLocaleDateString('ru-RU')}
</span>
</div>
</div>
</div>
{/* Правая часть - кнопки управления */}
<div className="flex items-center gap-2">
<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>
<Button
size="sm"
variant="ghost"
className="text-white/60 hover:text-white hover:bg-white/10 h-8 w-8 p-0"
onClick={() => toggleEmployeeExpansion(employee.id)}
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown 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>
{/* Сворачиваемый блок с табелем */}
{isExpanded && (
<div className="border-t border-white/10 p-4 bg-white/5">
<div className="mb-4">
<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>
{/* Интерактивная календарная сетка в стиле UI Kit */}
<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)
}
return calendarDays.map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="p-2"></div>
}
const status = generateDayStatus(day)
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-[50px] border rounded-lg cursor-pointer
transition-all duration-300 hover:scale-105 active:scale-95
${status === 'work'
? 'bg-gradient-to-br from-emerald-400/30 to-green-400/30 border-emerald-400/50 hover:border-emerald-300/70 shadow-lg shadow-emerald-500/20'
: status === 'weekend'
? 'bg-gradient-to-br from-slate-400/30 to-gray-400/30 border-slate-400/50 hover:border-slate-300/70 shadow-lg shadow-slate-500/20'
: status === 'vacation'
? 'bg-gradient-to-br from-blue-400/30 to-cyan-400/30 border-blue-400/50 hover:border-blue-300/70 shadow-lg shadow-blue-500/20'
: status === 'sick'
? 'bg-gradient-to-br from-amber-400/30 to-orange-400/30 border-amber-400/50 hover:border-amber-300/70 shadow-lg shadow-amber-500/20'
: 'bg-gradient-to-br from-red-400/30 to-rose-400/30 border-red-400/50 hover:border-red-300/70 shadow-lg shadow-red-500/20'
}
${isToday ? 'ring-2 ring-white/50' : ''}
`}
onClick={() => changeDayStatus(employee.id, day, status)}
>
<div className="flex flex-col items-center justify-center h-full">
<div className="flex items-center gap-1 mb-1">
{status === 'work' && <CheckCircle className="h-3 w-3" />}
{status === 'weekend' && <Clock className="h-3 w-3" />}
{status === 'vacation' && <Plane className="h-3 w-3" />}
{status === 'sick' && <Activity className="h-3 w-3" />}
{status === 'absent' && <XCircle className="h-3 w-3" />}
<span className="font-semibold text-sm text-white">{day}</span>
</div>
{status === 'work' && (
<span className="text-xs opacity-80">8ч</span>
)}
</div>
{isToday && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-white rounded-full"></div>
)}
</div>
)
})
})()}
</div>
{/* Статистика за месяц */}
<div className="grid grid-cols-4 gap-3 mt-4">
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-emerald-200 font-semibold text-lg">{stats.workDays}</p>
<p className="text-white/60 text-xs">Рабочих дней</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-sky-200 font-semibold text-lg">{stats.vacationDays}</p>
<p className="text-white/60 text-xs">Отпуск</p>
</div>
<div className="text-center p-3 bg-white/10 rounded-lg">
<p className="text-amber-200 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>
)}
</div>
)
})}
</div>
</div>
)
})()}
</Card>
</TabsContent>
<TabsContent value="reports">
{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.filter((e: Employee) => e.status === 'VACATION').length}
</p>
</div>
<Plane 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-yellow-400">
{employees.filter((e: Employee) => e.status === 'SICK').length}
</p>
</div>
<Activity className="h-8 w-8 text-yellow-400" />
</div>
</Card>
</div>
{/* Кнопка генерации отчета */}
<Card className="glass-card p-6">
<div className="text-center">
<h3 className="text-lg font-medium text-white mb-2">Сводный отчет</h3>
<p className="text-white/60 text-sm mb-4">
Экспортируйте данные всех сотрудников в CSV файл
</p>
<Button
onClick={generateReport}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white border-0 shadow-lg shadow-green-500/25 transition-all duration-300"
>
<Download className="h-4 w-4 mr-2" />
Скачать отчет CSV
</Button>
</div>
</Card>
</div>
)}
</TabsContent>
</Tabs>
</div>
</main>
</div>
)
}

View File

@ -92,7 +92,11 @@ export function SupplierOrdersDashboard() {
// Мутации для действий поставщика
const [supplierApproveOrder] = useMutation(SUPPLIER_APPROVE_ORDER, {
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
refetchQueries: [
{ query: GET_SUPPLY_ORDERS },
"GetMyProducts", // Обновляем товары поставщика
"GetWarehouseProducts", // Обновляем склад фулфилмента (если нужно)
],
awaitRefetchQueries: true,
onCompleted: (data) => {
if (data.supplierApproveOrder.success) {
@ -125,7 +129,10 @@ export function SupplierOrdersDashboard() {
});
const [supplierShipOrder] = useMutation(SUPPLIER_SHIP_ORDER, {
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
refetchQueries: [
{ query: GET_SUPPLY_ORDERS },
"GetMyProducts", // Обновляем товары поставщика для актуальных остатков
],
onCompleted: (data) => {
if (data.supplierShipOrder.success) {
toast.success(data.supplierShipOrder.message);

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import Image from "next/image";
import { useMutation, useQuery } from "@apollo/client";
import { Button } from "@/components/ui/button";
@ -14,7 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Card } from "@/components/ui/card";
import { CREATE_PRODUCT, UPDATE_PRODUCT } from "@/graphql/mutations";
import { CREATE_PRODUCT, UPDATE_PRODUCT, CHECK_ARTICLE_UNIQUENESS } from "@/graphql/mutations";
import { GET_CATEGORIES } from "@/graphql/queries";
import { X, Star, Upload, RefreshCw } from "lucide-react";
import { toast } from "sonner";
@ -82,10 +82,20 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
const [uploadingImages, setUploadingImages] = useState<Set<number>>(
new Set()
);
const [articleValidation, setArticleValidation] = useState<{
isChecking: boolean;
isValid: boolean;
message: string;
}>({
isChecking: false,
isValid: true,
message: '',
});
const fileInputRef = useRef<HTMLInputElement>(null);
const [createProduct, { loading: creating }] = useMutation(CREATE_PRODUCT);
const [updateProduct, { loading: updating }] = useMutation(UPDATE_PRODUCT);
const [checkArticleUniqueness] = useMutation(CHECK_ARTICLE_UNIQUENESS);
// Загружаем категории
const { data: categoriesData } = useQuery(GET_CATEGORIES);
@ -127,6 +137,82 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
}));
};
// Функция проверки уникальности артикула
const checkArticleUniquenessFn = useCallback(async (article: string) => {
if (!article || article.length < 3) {
setArticleValidation({ isChecking: false, isValid: true, message: '' });
return;
}
setArticleValidation({ isChecking: true, isValid: true, message: '' });
try {
const result = await checkArticleUniqueness({
variables: {
article,
excludeId: product?.id || null,
},
});
// Безопасная проверка наличия данных
if (!result?.data?.checkArticleUniqueness) {
setArticleValidation({
isChecking: false,
isValid: true,
message: '',
});
return;
}
const { isUnique, existingProduct } = result.data.checkArticleUniqueness;
if (isUnique) {
setArticleValidation({
isChecking: false,
isValid: true,
message: '✅ Артикул доступен',
});
} else {
setArticleValidation({
isChecking: false,
isValid: false,
message: `❌ Артикул уже используется товаром "${existingProduct?.name || 'неизвестным'}"`,
});
}
} catch (error) {
console.error('Error checking article uniqueness:', error);
setArticleValidation({
isChecking: false,
isValid: true,
message: '',
});
}
}, [checkArticleUniqueness, product?.id]);
// Debounced проверка артикула
useEffect(() => {
const timeoutId = setTimeout(() => {
if (formData.article && !formData.autoGenerateArticle && formData.article.length >= 3) {
// Проверяем только если артикул изменился по сравнению с оригинальным
if (!product || formData.article !== product.article) {
checkArticleUniquenessFn(formData.article);
} else {
// Если артикул не изменился при редактировании - валидация успешна
setArticleValidation({
isChecking: false,
isValid: true,
message: '✅ Текущий артикул товара'
});
}
} else if (formData.article.length < 3) {
// Сбрасываем валидацию если артикул слишком короткий
setArticleValidation({ isChecking: false, isValid: true, message: '' });
}
}, 500);
return () => clearTimeout(timeoutId);
}, [formData.article, formData.autoGenerateArticle, product, checkArticleUniquenessFn]);
const handleImageUpload = async (files: FileList) => {
const newUploadingIndexes = new Set<number>();
const startIndex = formData.images.length;
@ -232,6 +318,12 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
return;
}
// Проверяем уникальность артикула
if (!articleValidation.isValid) {
toast.error("Артикул уже используется другим товаром");
return;
}
console.log("📝 ФОРМА ДАННЫЕ ПЕРЕД ОТПРАВКОЙ:", formData);
try {
@ -288,6 +380,8 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
}
};
return (
<form onSubmit={handleSubmit} className="space-y-3">
{/* Верхняя часть - 2 колонки */}
@ -340,8 +434,18 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
</Button>
)}
</div>
{formData.autoGenerateArticle && (
{formData.autoGenerateArticle ? (
<p className="text-white/60 text-xs mt-1">Автогенерация</p>
) : (
<div className="mt-1">
{articleValidation.isChecking ? (
<p className="text-blue-400 text-xs">🔄 Проверка уникальности...</p>
) : articleValidation.message ? (
<p className={`text-xs ${articleValidation.isValid ? 'text-green-400' : 'text-red-400'}`}>
{articleValidation.message}
</p>
) : null}
</div>
)}
</div>

View File

@ -186,6 +186,61 @@ export function WarehouseStatistics({ products }: WarehouseStatisticsProps) {
</div>
</div>
</div>
{/* Уведомления о низких остатках */}
{(lowStockProducts.length > 0 || outOfStockProducts.length > 0) && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-400" />
<h3 className="text-sm font-semibold text-white">Предупреждения</h3>
</div>
{outOfStockProducts.length > 0 && (
<Card className="bg-red-500/10 backdrop-blur border-red-400/30 p-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-red-400" />
<span className="text-red-300 font-medium text-sm">Нет в наличии ({outOfStockProducts.length})</span>
</div>
<div className="space-y-1">
{outOfStockProducts.slice(0, 3).map(product => (
<div key={product.id} className="text-red-200 text-xs">
{product.name} (арт. {product.article})
</div>
))}
{outOfStockProducts.length > 3 && (
<div className="text-red-300 text-xs">
и ещё {outOfStockProducts.length - 3} товаров...
</div>
)}
</div>
</Card>
)}
{lowStockProducts.length > 0 && (
<Card className="bg-yellow-500/10 backdrop-blur border-yellow-400/30 p-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-400" />
<span className="text-yellow-300 font-medium text-sm">Мало на складе ({lowStockProducts.length})</span>
</div>
<div className="space-y-1">
{lowStockProducts.slice(0, 3).map(product => {
const stock = product.stock || product.quantity || 0;
return (
<div key={product.id} className="text-yellow-200 text-xs">
{product.name} (арт. {product.article}) - {stock} шт.
</div>
);
})}
{lowStockProducts.length > 3 && (
<div className="text-yellow-300 text-xs">
и ещё {lowStockProducts.length - 3} товаров...
</div>
)}
</div>
</Card>
)}
</div>
)}
</div>
);
}

View File

@ -876,6 +876,69 @@ export const DELETE_PRODUCT = gql`
}
`;
// Мутация для проверки уникальности артикула
export const CHECK_ARTICLE_UNIQUENESS = gql`
mutation CheckArticleUniqueness($article: String!, $excludeId: ID) {
checkArticleUniqueness(article: $article, excludeId: $excludeId) {
isUnique
existingProduct {
id
name
article
}
}
}
`;
// Мутация для резервирования товара (при заказе)
export const RESERVE_PRODUCT_STOCK = gql`
mutation ReserveProductStock($productId: ID!, $quantity: Int!) {
reserveProductStock(productId: $productId, quantity: $quantity) {
success
message
product {
id
quantity
ordered
stock
}
}
}
`;
// Мутация для освобождения резерва (при отмене заказа)
export const RELEASE_PRODUCT_RESERVE = gql`
mutation ReleaseProductReserve($productId: ID!, $quantity: Int!) {
releaseProductReserve(productId: $productId, quantity: $quantity) {
success
message
product {
id
quantity
ordered
stock
}
}
}
`;
// Мутация для обновления статуса "в пути"
export const UPDATE_PRODUCT_IN_TRANSIT = gql`
mutation UpdateProductInTransit($productId: ID!, $quantity: Int!, $operation: String!) {
updateProductInTransit(productId: $productId, quantity: $quantity, operation: $operation) {
success
message
product {
id
quantity
ordered
inTransit
stock
}
}
}
`;
// Мутации для корзины
export const ADD_TO_CART = gql`
mutation AddToCart($productId: ID!, $quantity: Int = 1) {

View File

@ -4393,6 +4393,227 @@ export const resolvers = {
}
},
// Проверка уникальности артикула
checkArticleUniqueness: async (
_: unknown,
args: { article: string; excludeId?: string },
context: Context
) => {
const { currentUser, prisma } = context;
if (!currentUser?.organization?.id) {
return {
isUnique: false,
existingProduct: null,
};
}
try {
const existingProduct = await prisma.product.findFirst({
where: {
article: args.article,
organizationId: currentUser.organization.id,
...(args.excludeId && { id: { not: args.excludeId } }),
},
select: {
id: true,
name: true,
article: true,
},
});
return {
isUnique: !existingProduct,
existingProduct,
};
} catch (error) {
console.error("Error checking article uniqueness:", error);
return {
isUnique: false,
existingProduct: null,
};
}
},
// Резервирование товара при создании заказа
reserveProductStock: async (
_: unknown,
args: { productId: string; quantity: number },
context: Context
) => {
const { currentUser, prisma } = context;
if (!currentUser?.organization?.id) {
return {
success: false,
message: "Необходимо авторизоваться",
};
}
try {
const product = await prisma.product.findUnique({
where: { id: args.productId },
});
if (!product) {
return {
success: false,
message: "Товар не найден",
};
}
// Проверяем доступность товара
const availableStock = (product.stock || product.quantity) - (product.ordered || 0);
if (availableStock < args.quantity) {
return {
success: false,
message: `Недостаточно товара на складе. Доступно: ${availableStock}, запрошено: ${args.quantity}`,
};
}
// Резервируем товар (увеличиваем поле ordered)
const updatedProduct = await prisma.product.update({
where: { id: args.productId },
data: {
ordered: (product.ordered || 0) + args.quantity,
},
});
console.log(`📦 Зарезервировано ${args.quantity} единиц товара ${product.name}`);
return {
success: true,
message: `Зарезервировано ${args.quantity} единиц товара`,
product: updatedProduct,
};
} catch (error) {
console.error("Error reserving product stock:", error);
return {
success: false,
message: "Ошибка при резервировании товара",
};
}
},
// Освобождение резерва при отмене заказа
releaseProductReserve: async (
_: unknown,
args: { productId: string; quantity: number },
context: Context
) => {
const { currentUser, prisma } = context;
if (!currentUser?.organization?.id) {
return {
success: false,
message: "Необходимо авторизоваться",
};
}
try {
const product = await prisma.product.findUnique({
where: { id: args.productId },
});
if (!product) {
return {
success: false,
message: "Товар не найден",
};
}
// Освобождаем резерв (уменьшаем поле ordered)
const newOrdered = Math.max((product.ordered || 0) - args.quantity, 0);
const updatedProduct = await prisma.product.update({
where: { id: args.productId },
data: {
ordered: newOrdered,
},
});
console.log(`🔄 Освобожден резерв ${args.quantity} единиц товара ${product.name}`);
return {
success: true,
message: `Освобожден резерв ${args.quantity} единиц товара`,
product: updatedProduct,
};
} catch (error) {
console.error("Error releasing product reserve:", error);
return {
success: false,
message: "Ошибка при освобождении резерва",
};
}
},
// Обновление статуса "в пути"
updateProductInTransit: async (
_: unknown,
args: { productId: string; quantity: number; operation: string },
context: Context
) => {
const { currentUser, prisma } = context;
if (!currentUser?.organization?.id) {
return {
success: false,
message: "Необходимо авторизоваться",
};
}
try {
const product = await prisma.product.findUnique({
where: { id: args.productId },
});
if (!product) {
return {
success: false,
message: "Товар не найден",
};
}
let newInTransit = product.inTransit || 0;
let newOrdered = product.ordered || 0;
if (args.operation === "ship") {
// При отгрузке: переводим из "заказано" в "в пути"
newInTransit = (product.inTransit || 0) + args.quantity;
newOrdered = Math.max((product.ordered || 0) - args.quantity, 0);
} else if (args.operation === "deliver") {
// При доставке: убираем из "в пути", добавляем в "продано"
newInTransit = Math.max((product.inTransit || 0) - args.quantity, 0);
}
const updatedProduct = await prisma.product.update({
where: { id: args.productId },
data: {
inTransit: newInTransit,
ordered: newOrdered,
...(args.operation === "deliver" && {
sold: (product.sold || 0) + args.quantity,
}),
},
});
console.log(`🚚 Обновлен статус "в пути" для товара ${product.name}: ${args.operation}`);
return {
success: true,
message: `Статус товара обновлен: ${args.operation}`,
product: updatedProduct,
};
} catch (error) {
console.error("Error updating product in transit:", error);
return {
success: false,
message: "Ошибка при обновлении статуса товара",
};
}
},
// Удалить товар
deleteProduct: async (
_: unknown,
@ -5608,6 +5829,24 @@ export const resolvers = {
})),
});
// 🔄 СИНХРОНИЗАЦИЯ: Обновляем товары поставщика (переводим из "в пути" в "продано")
for (const item of existingOrder.items) {
const product = await prisma.product.findUnique({
where: { id: item.product.id },
});
if (product) {
await prisma.product.update({
where: { id: item.product.id },
data: {
inTransit: Math.max((product.inTransit || 0) - item.quantity, 0),
sold: (product.sold || 0) + item.quantity,
},
});
console.log(`✅ Товар поставщика "${product.name}" обновлен: доставлено ${item.quantity} единиц`);
}
}
// Обновляем расходники
for (const item of existingOrder.items) {
console.log("📦 Обрабатываем товар:", {
@ -5735,6 +5974,48 @@ export const resolvers = {
}
console.log(`[DEBUG] Поставщик ${currentUser.organization.name} одобряет заказ ${args.id}`);
// 🔄 СИНХРОНИЗАЦИЯ ОСТАТКОВ: Резервируем товары у поставщика
const orderWithItems = await prisma.supplyOrder.findUnique({
where: { id: args.id },
include: {
items: {
include: {
product: true,
},
},
},
});
if (orderWithItems) {
for (const item of orderWithItems.items) {
// Резервируем товар (увеличиваем поле ordered)
const product = await prisma.product.findUnique({
where: { id: item.product.id },
});
if (product) {
const availableStock = (product.stock || product.quantity) - (product.ordered || 0);
if (availableStock < item.quantity) {
return {
success: false,
message: `Недостаточно товара "${product.name}" на складе. Доступно: ${availableStock}, требуется: ${item.quantity}`,
};
}
await prisma.product.update({
where: { id: item.product.id },
data: {
ordered: (product.ordered || 0) + item.quantity,
},
});
console.log(`📦 Зарезервировано ${item.quantity} единиц товара "${product.name}"`);
}
}
}
const updatedOrder = await prisma.supplyOrder.update({
where: { id: args.id },
data: { status: "SUPPLIER_APPROVED" },
@ -5759,7 +6040,7 @@ export const resolvers = {
console.log(`[DEBUG] Заказ ${args.id} успешно обновлен до статуса: ${updatedOrder.status}`);
return {
success: true,
message: "Заказ поставки одобрен поставщиком",
message: "Заказ поставки одобрен поставщиком. Товары зарезервированы.",
order: updatedOrder,
};
} catch (error) {
@ -5880,6 +6161,38 @@ export const resolvers = {
};
}
// 🔄 СИНХРОНИЗАЦИЯ ОСТАТКОВ: Переводим товары из "заказано" в "в пути"
const orderWithItems = await prisma.supplyOrder.findUnique({
where: { id: args.id },
include: {
items: {
include: {
product: true,
},
},
},
});
if (orderWithItems) {
for (const item of orderWithItems.items) {
const product = await prisma.product.findUnique({
where: { id: item.product.id },
});
if (product) {
await prisma.product.update({
where: { id: item.product.id },
data: {
ordered: Math.max((product.ordered || 0) - item.quantity, 0),
inTransit: (product.inTransit || 0) + item.quantity,
},
});
console.log(`🚚 Товар "${product.name}" переведен в статус "в пути": ${item.quantity} единиц`);
}
}
}
const updatedOrder = await prisma.supplyOrder.update({
where: { id: args.id },
data: { status: "SHIPPED" },
@ -5903,7 +6216,7 @@ export const resolvers = {
return {
success: true,
message: "Заказ отправлен поставщиком",
message: "Заказ отправлен поставщиком. Товары переведены в статус 'в пути'.",
order: updatedOrder,
};
} catch (error) {

View File

@ -228,6 +228,12 @@ export const typeDefs = gql`
createProduct(input: ProductInput!): ProductResponse!
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
deleteProduct(id: ID!): Boolean!
# Валидация и управление остатками товаров
checkArticleUniqueness(article: String!, excludeId: ID): ArticleUniquenessResponse!
reserveProductStock(productId: ID!, quantity: Int!): ProductStockResponse!
releaseProductReserve(productId: ID!, quantity: Int!): ProductStockResponse!
updateProductInTransit(productId: ID!, quantity: Int!, operation: String!): ProductStockResponse!
# Работа с категориями
createCategory(input: CategoryInput!): CategoryResponse!
@ -750,6 +756,17 @@ export const typeDefs = gql`
product: Product
}
type ArticleUniquenessResponse {
isUnique: Boolean!
existingProduct: Product
}
type ProductStockResponse {
success: Boolean!
message: String!
product: Product
}
input CategoryInput {
name: String!
}