From 50b02f97b7a54fab1b1160fcd7b22356033316d9 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Fri, 1 Aug 2025 12:10:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=20employees-dashboard.tsx=20=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=83=D0=BD=D0=B8=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=B0=D1=80=D1=82=D0=B8=D0=BA=D1=83=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=B0.=20=D0=9E=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BC=D1=83=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20GraphQL=20=D0=B4=D0=BB=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D1=83=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D1=83=D0=BB=D0=B0,=20=D0=B0=20=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D0=B6=D0=B5=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BE=20=D0=BD=D0=B8=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=D1=85=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BA=D0=B0=D1=85=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=B5.=20=D0=9E?= =?UTF-8?q?=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=20=D0=B4=D0=BB=D1=8F=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D1=81=D0=BA=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=BF=D1=8B=D1=82=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../employees/employees-dashboard.tsx.backup | 797 ------------------ .../supplier-orders-dashboard.tsx | 11 +- src/components/warehouse/product-form.tsx | 110 ++- .../warehouse/warehouse-statistics.tsx | 55 ++ src/graphql/mutations.ts | 63 ++ src/graphql/resolvers.ts | 317 ++++++- src/graphql/typedefs.ts | 17 + 7 files changed, 566 insertions(+), 804 deletions(-) delete mode 100644 src/components/employees/employees-dashboard.tsx.backup diff --git a/src/components/employees/employees-dashboard.tsx.backup b/src/components/employees/employees-dashboard.tsx.backup deleted file mode 100644 index 38d8869..0000000 --- a/src/components/employees/employees-dashboard.tsx.backup +++ /dev/null @@ -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(null) - const [deletingEmployeeId, setDeletingEmployeeId] = useState(null) - const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({}) - const [currentYear] = useState(new Date().getFullYear()) - const [currentMonth] = useState(new Date().getMonth()) - const [expandedEmployees, setExpandedEmployees] = useState>(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) => { - 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) => { - 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 ( -
- -
-
-
-
Загрузка сотрудников...
-
-
-
-
- ) - } - - return ( -
- -
-
- {/* Заголовок страницы */} -
-
- -
-

Управление сотрудниками

-

Личные данные, табель работы и учет

-
-
- - - -
- - {/* Поиск */} - -
- - setSearchQuery(e.target.value)} - className="glass-input pl-10" - /> -
-
- - {/* Форма добавления сотрудника */} - {showAddForm && ( - setShowAddForm(false)} - isLoading={createLoading} - /> - )} - - {/* Форма редактирования сотрудника */} - {showEditForm && editingEmployee && ( - { - setShowEditForm(false) - setEditingEmployee(null) - }} - isLoading={createLoading} - /> - )} - - {/* Основной контент с вкладками */} - - - - - Сотрудники и табель - - - - - Отчеты - - - - - - {(() => { - 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 ( -
-
-
- -
-

- {searchQuery ? 'Сотрудники не найдены' : 'У вас пока нет сотрудников'} -

-

- {searchQuery - ? 'Попробуйте изменить критерии поиска' - : 'Добавьте первого сотрудника в вашу команду' - } -

- {!searchQuery && ( - - )} -
-
- ) - } - - return ( -
- {/* Легенда статусов */} -
-
-
- -
- Рабочий день -
-
-
- -
- Выходной -
-
-
- -
- Отпуск -
-
-
- -
- Больничный -
-
-
- -
- Прогул -
-
- - {/* Строчный список сотрудников с сворачиваемым табелем */} -
- {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 ( -
- {/* Строчка сотрудника */} -
- {/* Левая часть - аватар и основная информация */} -
- - {employee.avatar ? ( - - ) : null} - - {employee.firstName.charAt(0)}{employee.lastName.charAt(0)} - - - - {/* Основная информация */} -
-
-

- {employee.firstName} {employee.lastName} -

- {employee.position} -
-
- - - {employee.phone} - - {employee.email && ( - - - {employee.email} - - )} - - - Принят: {new Date(employee.hireDate).toLocaleDateString('ru-RU')} - -
-
-
- - {/* Правая часть - кнопки управления */} -
- - - - - - - - - - - Уволить сотрудника? - - Вы уверены, что хотите уволить сотрудника {employee.firstName} {employee.lastName}? - Это действие нельзя отменить. - - - - - Отмена - - handleEmployeeDeleted(employee.id)} - disabled={deletingEmployeeId === employee.id} - className="bg-red-600 hover:bg-red-700 text-white" - > - {deletingEmployeeId === employee.id ? 'Увольнение...' : 'Уволить'} - - - - -
-
- - {/* Сворачиваемый блок с табелем */} - {isExpanded && ( -
-
-

- - Табель работы за {new Date().toLocaleDateString('ru-RU', { month: 'long' })} -

- - {/* Интерактивная календарная сетка в стиле UI Kit */} -
- {/* Заголовки дней недели */} - {['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].map(day => ( -
- {day} -
- ))} - - {/* Дни месяца с интерактивным стилем */} - {(() => { - 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
- } - - const status = generateDayStatus(day) - const isToday = new Date().getDate() === day && - new Date().getMonth() === currentMonth && - new Date().getFullYear() === currentYear - - return ( -
changeDayStatus(employee.id, day, status)} - > -
-
- {status === 'work' && } - {status === 'weekend' && } - {status === 'vacation' && } - {status === 'sick' && } - {status === 'absent' && } - {day} -
- {status === 'work' && ( - - )} -
- - {isToday && ( -
- )} -
- ) - }) - })()} -
- - {/* Статистика за месяц */} -
-
-

{stats.workDays}

-

Рабочих дней

-
-
-

{stats.vacationDays}

-

Отпуск

-
-
-

{stats.sickDays}

-

Больничный

-
-
-

{stats.totalHours}ч

-

Всего часов

-
-
-
- )} -
- ) - })} -
-
- ) - })()} - - - - - {employees.length === 0 ? ( - -
-
-
- -
-

Нет данных для отчетов

-

- Добавьте сотрудников, чтобы генерировать отчеты и аналитику -

- -
-
-
- ) : ( -
- {/* Статистические карточки */} -
- -
-
-

Всего сотрудников

-

{employees.length}

-
- -
-
- -
-
-

Активных

-

- {employees.filter((e: Employee) => e.status === 'ACTIVE').length} -

-
- -
-
- -
-
-

В отпуске

-

- {employees.filter((e: Employee) => e.status === 'VACATION').length} -

-
- -
-
- -
-
-

На больничном

-

- {employees.filter((e: Employee) => e.status === 'SICK').length} -

-
- -
-
-
- - {/* Кнопка генерации отчета */} - -
-

Сводный отчет

-

- Экспортируйте данные всех сотрудников в CSV файл -

- -
-
-
- )} -
- -
-
-
- ) -} \ No newline at end of file diff --git a/src/components/supplier-orders/supplier-orders-dashboard.tsx b/src/components/supplier-orders/supplier-orders-dashboard.tsx index 3d6f772..cce2052 100644 --- a/src/components/supplier-orders/supplier-orders-dashboard.tsx +++ b/src/components/supplier-orders/supplier-orders-dashboard.tsx @@ -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); diff --git a/src/components/warehouse/product-form.tsx b/src/components/warehouse/product-form.tsx index ce6c007..6ba04f4 100644 --- a/src/components/warehouse/product-form.tsx +++ b/src/components/warehouse/product-form.tsx @@ -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>( new Set() ); + const [articleValidation, setArticleValidation] = useState<{ + isChecking: boolean; + isValid: boolean; + message: string; + }>({ + isChecking: false, + isValid: true, + message: '', + }); const fileInputRef = useRef(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(); 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 (
{/* Верхняя часть - 2 колонки */} @@ -340,8 +434,18 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) { )} - {formData.autoGenerateArticle && ( + {formData.autoGenerateArticle ? (

Автогенерация

+ ) : ( +
+ {articleValidation.isChecking ? ( +

🔄 Проверка уникальности...

+ ) : articleValidation.message ? ( +

+ {articleValidation.message} +

+ ) : null} +
)} diff --git a/src/components/warehouse/warehouse-statistics.tsx b/src/components/warehouse/warehouse-statistics.tsx index 5071d80..66ea1d4 100644 --- a/src/components/warehouse/warehouse-statistics.tsx +++ b/src/components/warehouse/warehouse-statistics.tsx @@ -186,6 +186,61 @@ export function WarehouseStatistics({ products }: WarehouseStatisticsProps) { + + {/* Уведомления о низких остатках */} + {(lowStockProducts.length > 0 || outOfStockProducts.length > 0) && ( +
+
+ +

Предупреждения

+
+ + {outOfStockProducts.length > 0 && ( + +
+ + Нет в наличии ({outOfStockProducts.length}) +
+
+ {outOfStockProducts.slice(0, 3).map(product => ( +
+ • {product.name} (арт. {product.article}) +
+ ))} + {outOfStockProducts.length > 3 && ( +
+ и ещё {outOfStockProducts.length - 3} товаров... +
+ )} +
+
+ )} + + {lowStockProducts.length > 0 && ( + +
+ + Мало на складе ({lowStockProducts.length}) +
+
+ {lowStockProducts.slice(0, 3).map(product => { + const stock = product.stock || product.quantity || 0; + return ( +
+ • {product.name} (арт. {product.article}) - {stock} шт. +
+ ); + })} + {lowStockProducts.length > 3 && ( +
+ и ещё {lowStockProducts.length - 3} товаров... +
+ )} +
+
+ )} +
+ )} ); } \ No newline at end of file diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 2cded69..0f5f00d 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -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) { diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 574259a..44ec6a1 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -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) { diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 31f3de4..83c650c 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -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! }