Удален резервный файл employees-dashboard.tsx и добавлены новые функции для проверки уникальности артикула в форме продукта. Обновлены мутации GraphQL для поддержки проверки уникальности артикула, а также добавлены уведомления о низких остатках на складе. Оптимизирован интерфейс для улучшения пользовательского опыта.
This commit is contained in:
@ -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>
|
||||
)
|
||||
}
|
@ -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);
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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!
|
||||
}
|
||||
|
Reference in New Issue
Block a user