Compare commits
2 Commits
b314bb6466
...
b095b3a5a7
Author | SHA1 | Date | |
---|---|---|---|
b095b3a5a7 | |||
50b02f97b7 |
@ -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, {
|
const [supplierApproveOrder] = useMutation(SUPPLIER_APPROVE_ORDER, {
|
||||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
refetchQueries: [
|
||||||
|
{ query: GET_SUPPLY_ORDERS },
|
||||||
|
"GetMyProducts", // Обновляем товары поставщика
|
||||||
|
"GetWarehouseProducts", // Обновляем склад фулфилмента (если нужно)
|
||||||
|
],
|
||||||
awaitRefetchQueries: true,
|
awaitRefetchQueries: true,
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
if (data.supplierApproveOrder.success) {
|
if (data.supplierApproveOrder.success) {
|
||||||
@ -125,7 +129,10 @@ export function SupplierOrdersDashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [supplierShipOrder] = useMutation(SUPPLIER_SHIP_ORDER, {
|
const [supplierShipOrder] = useMutation(SUPPLIER_SHIP_ORDER, {
|
||||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
refetchQueries: [
|
||||||
|
{ query: GET_SUPPLY_ORDERS },
|
||||||
|
"GetMyProducts", // Обновляем товары поставщика для актуальных остатков
|
||||||
|
],
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
if (data.supplierShipOrder.success) {
|
if (data.supplierShipOrder.success) {
|
||||||
toast.success(data.supplierShipOrder.message);
|
toast.success(data.supplierShipOrder.message);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useMutation, useQuery } from "@apollo/client";
|
import { useMutation, useQuery } from "@apollo/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -14,7 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Card } from "@/components/ui/card";
|
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 { GET_CATEGORIES } from "@/graphql/queries";
|
||||||
import { X, Star, Upload, RefreshCw } from "lucide-react";
|
import { X, Star, Upload, RefreshCw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -82,10 +82,20 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
const [uploadingImages, setUploadingImages] = useState<Set<number>>(
|
const [uploadingImages, setUploadingImages] = useState<Set<number>>(
|
||||||
new Set()
|
new Set()
|
||||||
);
|
);
|
||||||
|
const [articleValidation, setArticleValidation] = useState<{
|
||||||
|
isChecking: boolean;
|
||||||
|
isValid: boolean;
|
||||||
|
message: string;
|
||||||
|
}>({
|
||||||
|
isChecking: false,
|
||||||
|
isValid: true,
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [createProduct, { loading: creating }] = useMutation(CREATE_PRODUCT);
|
const [createProduct, { loading: creating }] = useMutation(CREATE_PRODUCT);
|
||||||
const [updateProduct, { loading: updating }] = useMutation(UPDATE_PRODUCT);
|
const [updateProduct, { loading: updating }] = useMutation(UPDATE_PRODUCT);
|
||||||
|
const [checkArticleUniqueness] = useMutation(CHECK_ARTICLE_UNIQUENESS);
|
||||||
|
|
||||||
// Загружаем категории
|
// Загружаем категории
|
||||||
const { data: categoriesData } = useQuery(GET_CATEGORIES);
|
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 handleImageUpload = async (files: FileList) => {
|
||||||
const newUploadingIndexes = new Set<number>();
|
const newUploadingIndexes = new Set<number>();
|
||||||
const startIndex = formData.images.length;
|
const startIndex = formData.images.length;
|
||||||
@ -232,6 +318,12 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем уникальность артикула
|
||||||
|
if (!articleValidation.isValid) {
|
||||||
|
toast.error("Артикул уже используется другим товаром");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("📝 ФОРМА ДАННЫЕ ПЕРЕД ОТПРАВКОЙ:", formData);
|
console.log("📝 ФОРМА ДАННЫЕ ПЕРЕД ОТПРАВКОЙ:", formData);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -288,6 +380,8 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
{/* Верхняя часть - 2 колонки */}
|
{/* Верхняя часть - 2 колонки */}
|
||||||
@ -340,8 +434,18 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{formData.autoGenerateArticle && (
|
{formData.autoGenerateArticle ? (
|
||||||
<p className="text-white/60 text-xs mt-1">Автогенерация</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
@ -186,6 +186,61 @@ export function WarehouseStatistics({ products }: WarehouseStatisticsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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`
|
export const ADD_TO_CART = gql`
|
||||||
mutation AddToCart($productId: ID!, $quantity: Int = 1) {
|
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 (
|
deleteProduct: async (
|
||||||
_: unknown,
|
_: 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) {
|
for (const item of existingOrder.items) {
|
||||||
console.log("📦 Обрабатываем товар:", {
|
console.log("📦 Обрабатываем товар:", {
|
||||||
@ -5735,6 +5974,48 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[DEBUG] Поставщик ${currentUser.organization.name} одобряет заказ ${args.id}`);
|
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({
|
const updatedOrder = await prisma.supplyOrder.update({
|
||||||
where: { id: args.id },
|
where: { id: args.id },
|
||||||
data: { status: "SUPPLIER_APPROVED" },
|
data: { status: "SUPPLIER_APPROVED" },
|
||||||
@ -5759,7 +6040,7 @@ export const resolvers = {
|
|||||||
console.log(`[DEBUG] Заказ ${args.id} успешно обновлен до статуса: ${updatedOrder.status}`);
|
console.log(`[DEBUG] Заказ ${args.id} успешно обновлен до статуса: ${updatedOrder.status}`);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Заказ поставки одобрен поставщиком",
|
message: "Заказ поставки одобрен поставщиком. Товары зарезервированы.",
|
||||||
order: updatedOrder,
|
order: updatedOrder,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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({
|
const updatedOrder = await prisma.supplyOrder.update({
|
||||||
where: { id: args.id },
|
where: { id: args.id },
|
||||||
data: { status: "SHIPPED" },
|
data: { status: "SHIPPED" },
|
||||||
@ -5903,7 +6216,7 @@ export const resolvers = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Заказ отправлен поставщиком",
|
message: "Заказ отправлен поставщиком. Товары переведены в статус 'в пути'.",
|
||||||
order: updatedOrder,
|
order: updatedOrder,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -228,6 +228,12 @@ export const typeDefs = gql`
|
|||||||
createProduct(input: ProductInput!): ProductResponse!
|
createProduct(input: ProductInput!): ProductResponse!
|
||||||
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
|
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
|
||||||
deleteProduct(id: ID!): Boolean!
|
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!
|
createCategory(input: CategoryInput!): CategoryResponse!
|
||||||
@ -750,6 +756,17 @@ export const typeDefs = gql`
|
|||||||
product: Product
|
product: Product
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArticleUniquenessResponse {
|
||||||
|
isUnique: Boolean!
|
||||||
|
existingProduct: Product
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductStockResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String!
|
||||||
|
product: Product
|
||||||
|
}
|
||||||
|
|
||||||
input CategoryInput {
|
input CategoryInput {
|
||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user