Добавлены новые модели и мутации для навигационных категорий, включая создание, обновление и удаление. Обновлены типы GraphQL и резолверы для обработки навигационных категорий, что улучшает структуру данных и функциональность. В боковое меню добавлен новый элемент для навигации по категориям. Реализован кэш для оптимизации запросов к API, что повышает производительность приложения.
This commit is contained in:
@ -59,6 +59,28 @@ model Category {
|
|||||||
@@map("categories")
|
@@map("categories")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NavigationCategory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// Привязка к категории PartsIndex
|
||||||
|
partsIndexCatalogId String // ID каталога из PartsIndex API
|
||||||
|
partsIndexGroupId String? // ID группы из PartsIndex API (необязательно)
|
||||||
|
|
||||||
|
// Только иконка - название берем из PartsIndex
|
||||||
|
icon String? // URL иконки в S3
|
||||||
|
|
||||||
|
// Настройки отображения
|
||||||
|
isHidden Boolean @default(false)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Уникальная комбинация catalogId + groupId
|
||||||
|
@@unique([partsIndexCatalogId, partsIndexGroupId])
|
||||||
|
@@map("navigation_categories")
|
||||||
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
123
src/app/api/debug-partsindex/route.ts
Normal file
123
src/app/api/debug-partsindex/route.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { partsIndexService } from '@/lib/partsindex-service';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const action = url.searchParams.get('action');
|
||||||
|
const prefix = url.searchParams.get('prefix');
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'stats':
|
||||||
|
// Возвращаем статистику кэша
|
||||||
|
const stats = partsIndexService.getCacheStats();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...stats,
|
||||||
|
formattedEntries: stats.entries.map(entry => ({
|
||||||
|
...entry,
|
||||||
|
sizeKB: Math.round(entry.size / 1024 * 100) / 100,
|
||||||
|
ageMinutes: Math.round(entry.age / (1000 * 60) * 100) / 100,
|
||||||
|
ttlMinutes: Math.round(entry.ttl / (1000 * 60) * 100) / 100,
|
||||||
|
isExpired: entry.age > entry.ttl
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'clear':
|
||||||
|
if (prefix) {
|
||||||
|
partsIndexService.clearCacheByPrefix(prefix);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Кэш с префиксом "${prefix}" очищен`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
partsIndexService.clearCache();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Весь кэш PartsIndex очищен'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'test':
|
||||||
|
// Тестовый запрос для проверки работы кэша
|
||||||
|
const catalogs = await partsIndexService.getCatalogs('ru');
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
catalogsCount: catalogs.length,
|
||||||
|
catalogs: catalogs.slice(0, 3).map(c => ({ id: c.id, name: c.name }))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Неизвестное действие. Доступные: stats, clear, test'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка в debug-partsindex API:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Внутренняя ошибка сервера'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { action, catalogId, lang = 'ru' } = body;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'preload':
|
||||||
|
// Предзагрузка данных в кэш
|
||||||
|
console.log('🔄 Предзагрузка данных PartsIndex в кэш...');
|
||||||
|
|
||||||
|
const catalogs = await partsIndexService.getCatalogs(lang);
|
||||||
|
console.log(`✅ Загружено ${catalogs.length} каталогов`);
|
||||||
|
|
||||||
|
if (catalogId) {
|
||||||
|
// Загружаем группы конкретного каталога
|
||||||
|
const groups = await partsIndexService.getCatalogGroups(catalogId, lang);
|
||||||
|
console.log(`✅ Загружено ${groups.length} групп для каталога ${catalogId}`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
catalogsCount: catalogs.length,
|
||||||
|
groupsCount: groups.length,
|
||||||
|
catalogId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Загружаем полную структуру
|
||||||
|
const categoriesWithGroups = await partsIndexService.getCategoriesWithGroups(lang);
|
||||||
|
const totalGroups = categoriesWithGroups.reduce((acc, cat) => acc + cat.groups.length, 0);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
catalogsCount: catalogs.length,
|
||||||
|
categoriesWithGroupsCount: categoriesWithGroups.length,
|
||||||
|
totalGroups
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Неизвестное действие. Доступные: preload'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка в debug-partsindex POST API:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Внутренняя ошибка сервера'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
11
src/app/dashboard/navigation/page.tsx
Normal file
11
src/app/dashboard/navigation/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import NavigationCategoryTree from '@/components/navigation/NavigationCategoryTree'
|
||||||
|
|
||||||
|
export default function NavigationPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6 px-4">
|
||||||
|
<NavigationCategoryTree />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -302,6 +302,8 @@ export const CategoryForm = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Настройки */}
|
{/* Настройки */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium">Настройки</h3>
|
<h3 className="text-sm font-medium">Настройки</h3>
|
||||||
|
381
src/components/navigation/NavigationCategoryForm.tsx
Normal file
381
src/components/navigation/NavigationCategoryForm.tsx
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql/queries'
|
||||||
|
import { Loader2, Upload, X, Image as ImageIcon, Folder, FolderOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PartsIndexCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image?: string
|
||||||
|
groups?: PartsIndexGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartsIndexGroup {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image?: string
|
||||||
|
subgroups?: PartsIndexGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationCategory {
|
||||||
|
id?: string
|
||||||
|
partsIndexCatalogId: string
|
||||||
|
partsIndexGroupId?: string
|
||||||
|
icon?: string
|
||||||
|
isHidden: boolean
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationCategoryFormProps {
|
||||||
|
category?: NavigationCategory | null
|
||||||
|
onSubmit: (data: any) => void
|
||||||
|
onCancel: () => void
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavigationCategoryForm({
|
||||||
|
category,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
isLoading = false
|
||||||
|
}: NavigationCategoryFormProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
partsIndexCatalogId: '',
|
||||||
|
partsIndexGroupId: '',
|
||||||
|
icon: '',
|
||||||
|
isHidden: false,
|
||||||
|
sortOrder: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const [selectedCatalog, setSelectedCatalog] = useState<PartsIndexCategory | null>(null)
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<PartsIndexGroup | null>(null)
|
||||||
|
|
||||||
|
// Загрузка категорий PartsIndex
|
||||||
|
const { data: categoriesData, loading: categoriesLoading, error: categoriesError } = useQuery(
|
||||||
|
GET_PARTSINDEX_CATEGORIES,
|
||||||
|
{
|
||||||
|
variables: { lang: 'ru' },
|
||||||
|
errorPolicy: 'all'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const categories = categoriesData?.partsIndexCategoriesWithGroups || []
|
||||||
|
|
||||||
|
// Заполнение формы при редактировании
|
||||||
|
useEffect(() => {
|
||||||
|
if (category) {
|
||||||
|
setFormData({
|
||||||
|
partsIndexCatalogId: category.partsIndexCatalogId || '',
|
||||||
|
partsIndexGroupId: category.partsIndexGroupId || '',
|
||||||
|
icon: category.icon || '',
|
||||||
|
isHidden: category.isHidden || false,
|
||||||
|
sortOrder: category.sortOrder || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Находим выбранный каталог и группу
|
||||||
|
const catalog = categories.find(c => c.id === category.partsIndexCatalogId)
|
||||||
|
if (catalog) {
|
||||||
|
setSelectedCatalog(catalog)
|
||||||
|
|
||||||
|
if (category.partsIndexGroupId && catalog.groups) {
|
||||||
|
const group = findGroupById(catalog.groups, category.partsIndexGroupId)
|
||||||
|
setSelectedGroup(group || null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [category, categories])
|
||||||
|
|
||||||
|
// Рекурсивный поиск группы по ID
|
||||||
|
const findGroupById = (groups: PartsIndexGroup[], groupId: string): PartsIndexGroup | null => {
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.id === groupId) {
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
if (group.subgroups && group.subgroups.length > 0) {
|
||||||
|
const found = findGroupById(group.subgroups, groupId)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение всех групп из каталога (включая подгруппы)
|
||||||
|
const getAllGroups = (groups: PartsIndexGroup[], level = 0): Array<PartsIndexGroup & { level: number }> => {
|
||||||
|
const result: Array<PartsIndexGroup & { level: number }> = []
|
||||||
|
|
||||||
|
groups.forEach(group => {
|
||||||
|
result.push({ ...group, level })
|
||||||
|
if (group.subgroups && group.subgroups.length > 0) {
|
||||||
|
result.push(...getAllGroups(group.subgroups, level + 1))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: any) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCatalogSelect = (catalogId: string) => {
|
||||||
|
const catalog = categories.find(c => c.id === catalogId)
|
||||||
|
setSelectedCatalog(catalog || null)
|
||||||
|
setSelectedGroup(null)
|
||||||
|
|
||||||
|
handleInputChange('partsIndexCatalogId', catalogId)
|
||||||
|
handleInputChange('partsIndexGroupId', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGroupSelect = (groupId: string) => {
|
||||||
|
if (groupId === '__CATALOG_ROOT__') {
|
||||||
|
setSelectedGroup(null)
|
||||||
|
handleInputChange('partsIndexGroupId', '')
|
||||||
|
} else if (selectedCatalog?.groups) {
|
||||||
|
const group = findGroupById(selectedCatalog.groups, groupId)
|
||||||
|
setSelectedGroup(group || null)
|
||||||
|
handleInputChange('partsIndexGroupId', groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const result = e.target?.result as string
|
||||||
|
handleInputChange('icon', result)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!formData.partsIndexCatalogId) {
|
||||||
|
alert('Выберите каталог')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayName = () => {
|
||||||
|
if (selectedGroup) {
|
||||||
|
return `${selectedCatalog?.name} → ${selectedGroup.name}`
|
||||||
|
}
|
||||||
|
return selectedCatalog?.name || 'Выберите категорию'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoriesLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||||
|
<span className="ml-2">Загрузка категорий PartsIndex...</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoriesError) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8">
|
||||||
|
<div className="text-center text-red-600">
|
||||||
|
Ошибка загрузки категорий PartsIndex: {categoriesError.message}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{category ? 'Редактировать иконку категории' : 'Добавить иконку для категории'}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Выберите категорию из PartsIndex и загрузите иконку для отображения в навигации сайта
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Выбор каталога */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="catalog">Каталог PartsIndex</Label>
|
||||||
|
<Select value={formData.partsIndexCatalogId} onValueChange={handleCatalogSelect}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="Выберите каталог из PartsIndex" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((catalog) => (
|
||||||
|
<SelectItem key={catalog.id} value={catalog.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="h-4 w-4 text-blue-600" />
|
||||||
|
{catalog.name}
|
||||||
|
{catalog.groups && (
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{catalog.groups.length} групп
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Выбор группы (если есть группы в каталоге) */}
|
||||||
|
{selectedCatalog?.groups && selectedCatalog.groups.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="group">Группа (необязательно)</Label>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
|
Оставьте пустым для добавления иконки всему каталогу
|
||||||
|
</p>
|
||||||
|
<Select value={formData.partsIndexGroupId || '__CATALOG_ROOT__'} onValueChange={handleGroupSelect}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue placeholder="Выберите группу или оставьте пустым" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__CATALOG_ROOT__">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4 text-gray-400" />
|
||||||
|
Весь каталог "{selectedCatalog.name}"
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
{getAllGroups(selectedCatalog.groups).map((group) => (
|
||||||
|
<SelectItem key={group.id} value={group.id}>
|
||||||
|
<div className="flex items-center gap-2" style={{ paddingLeft: `${group.level * 16}px` }}>
|
||||||
|
<Folder className="h-4 w-4 text-orange-600" />
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Предварительный просмотр выбранной категории */}
|
||||||
|
{formData.partsIndexCatalogId && (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<ImageIcon className="h-4 w-4 text-blue-600" />
|
||||||
|
Выбранная категория:
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-600">
|
||||||
|
{getDisplayName()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Загрузка иконки */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="icon">Иконка категории</Label>
|
||||||
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
|
Небольшая иконка для отображения в навигационном меню (рекомендуется 32x32 пикселя)
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{formData.icon && (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<img
|
||||||
|
src={formData.icon}
|
||||||
|
alt="Превью иконки"
|
||||||
|
className="w-16 h-16 object-cover rounded-lg border"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="absolute -top-2 -right-2 h-6 w-6 p-0"
|
||||||
|
onClick={() => handleInputChange('icon', '')}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
className="hidden"
|
||||||
|
id="icon-upload"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => document.getElementById('icon-upload')?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Загрузить иконку
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Настройки отображения */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="isHidden"
|
||||||
|
checked={formData.isHidden}
|
||||||
|
onCheckedChange={(checked) => handleInputChange('isHidden', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isHidden" className="text-sm font-medium">
|
||||||
|
Скрыть категорию в навигации
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sortOrder">Порядок сортировки</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="sortOrder"
|
||||||
|
value={formData.sortOrder}
|
||||||
|
onChange={(e) => handleInputChange('sortOrder', parseInt(e.target.value) || 0)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Меньшее число = выше в списке
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки */}
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !formData.partsIndexCatalogId}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
{category ? 'Сохранить изменения' : 'Добавить иконку'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
377
src/components/navigation/NavigationCategoryTree.tsx
Normal file
377
src/components/navigation/NavigationCategoryTree.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
GET_NAVIGATION_CATEGORIES,
|
||||||
|
GET_PARTSINDEX_CATEGORIES
|
||||||
|
} from '@/lib/graphql/queries'
|
||||||
|
import {
|
||||||
|
CREATE_NAVIGATION_CATEGORY,
|
||||||
|
UPDATE_NAVIGATION_CATEGORY,
|
||||||
|
DELETE_NAVIGATION_CATEGORY
|
||||||
|
} from '@/lib/graphql/mutations'
|
||||||
|
import { Loader2, Plus, Edit, Trash2, Image as ImageIcon, Folder, Settings, Eye, EyeOff } from 'lucide-react'
|
||||||
|
import NavigationCategoryForm from './NavigationCategoryForm'
|
||||||
|
|
||||||
|
interface NavigationCategory {
|
||||||
|
id: string
|
||||||
|
partsIndexCatalogId: string
|
||||||
|
partsIndexGroupId?: string
|
||||||
|
icon?: string
|
||||||
|
isHidden: boolean
|
||||||
|
sortOrder: number
|
||||||
|
name: string
|
||||||
|
catalogName: string
|
||||||
|
groupName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartsIndexCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image?: string
|
||||||
|
groups?: PartsIndexGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartsIndexGroup {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image?: string
|
||||||
|
subgroups?: PartsIndexGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavigationCategoryTree() {
|
||||||
|
const [editingCategory, setEditingCategory] = useState<NavigationCategory | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
// Загрузка навигационных категорий (с иконками)
|
||||||
|
const {
|
||||||
|
data: navigationData,
|
||||||
|
loading: navigationLoading,
|
||||||
|
error: navigationError,
|
||||||
|
refetch: refetchNavigation
|
||||||
|
} = useQuery(GET_NAVIGATION_CATEGORIES, {
|
||||||
|
errorPolicy: 'all'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Загрузка категорий PartsIndex
|
||||||
|
const {
|
||||||
|
data: partsIndexData,
|
||||||
|
loading: partsIndexLoading,
|
||||||
|
error: partsIndexError
|
||||||
|
} = useQuery(GET_PARTSINDEX_CATEGORIES, {
|
||||||
|
variables: { lang: 'ru' },
|
||||||
|
errorPolicy: 'all'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Мутации
|
||||||
|
const [createCategory, { loading: creating }] = useMutation(CREATE_NAVIGATION_CATEGORY, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetchNavigation()
|
||||||
|
handleCloseForm()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Ошибка создания категории:', error)
|
||||||
|
alert('Не удалось создать иконку для категории')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [updateCategory, { loading: updating }] = useMutation(UPDATE_NAVIGATION_CATEGORY, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetchNavigation()
|
||||||
|
handleCloseForm()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Ошибка обновления категории:', error)
|
||||||
|
alert('Не удалось обновить иконку категории')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const [deleteCategory, { loading: deleting }] = useMutation(DELETE_NAVIGATION_CATEGORY, {
|
||||||
|
onCompleted: () => {
|
||||||
|
refetchNavigation()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Ошибка удаления категории:', error)
|
||||||
|
alert('Не удалось удалить иконку категории')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const navigationCategories = navigationData?.navigationCategories || []
|
||||||
|
const partsIndexCategories = partsIndexData?.partsIndexCategoriesWithGroups || []
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: any) => {
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
await updateCategory({
|
||||||
|
variables: {
|
||||||
|
id: editingCategory.id,
|
||||||
|
input: formData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createCategory({
|
||||||
|
variables: {
|
||||||
|
input: formData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (category: NavigationCategory) => {
|
||||||
|
setEditingCategory(category)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (category: NavigationCategory) => {
|
||||||
|
if (confirm(`Удалить иконку для категории "${category.name}"?`)) {
|
||||||
|
await deleteCategory({
|
||||||
|
variables: { id: category.id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setEditingCategory(null)
|
||||||
|
setShowForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для получения полного пути категории
|
||||||
|
const getCategoryPath = (catalogId: string, groupId?: string) => {
|
||||||
|
const catalog = partsIndexCategories.find(c => c.id === catalogId)
|
||||||
|
if (!catalog) return 'Неизвестная категория'
|
||||||
|
|
||||||
|
if (!groupId) return catalog.name
|
||||||
|
|
||||||
|
// Рекурсивный поиск группы
|
||||||
|
const findGroup = (groups: PartsIndexGroup[]): PartsIndexGroup | null => {
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.id === groupId) return group
|
||||||
|
if (group.subgroups) {
|
||||||
|
const found = findGroup(group.subgroups)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = catalog.groups ? findGroup(catalog.groups) : null
|
||||||
|
return group ? `${catalog.name} → ${group.name}` : catalog.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка есть ли иконка для категории
|
||||||
|
const hasIcon = (catalogId: string, groupId?: string) => {
|
||||||
|
return navigationCategories.some(nav =>
|
||||||
|
nav.partsIndexCatalogId === catalogId &&
|
||||||
|
nav.partsIndexGroupId === groupId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение иконки для категории
|
||||||
|
const getIcon = (catalogId: string, groupId?: string) => {
|
||||||
|
return navigationCategories.find(nav =>
|
||||||
|
nav.partsIndexCatalogId === catalogId &&
|
||||||
|
nav.partsIndexGroupId === groupId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showForm) {
|
||||||
|
return (
|
||||||
|
<NavigationCategoryForm
|
||||||
|
category={editingCategory}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCloseForm}
|
||||||
|
isLoading={creating || updating}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigationLoading || partsIndexLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||||
|
<span className="ml-2">Загрузка категорий...</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigationError || partsIndexError) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
Ошибка загрузки категорий: {navigationError?.message || partsIndexError?.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">Иконки навигации</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Привязка иконок к категориям PartsIndex для отображения в навигации сайта
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить иконку
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="h-5 w-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">Каталогов PartsIndex</div>
|
||||||
|
<div className="text-2xl font-bold">{partsIndexCategories.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-5 w-5 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">С иконками</div>
|
||||||
|
<div className="text-2xl font-bold">{navigationCategories.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-5 w-5 text-orange-600" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">Активных</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{navigationCategories.filter(cat => !cat.isHidden).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список категорий с иконками */}
|
||||||
|
{navigationCategories.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Категории с иконками</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{navigationCategories
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((navCategory) => (
|
||||||
|
<div
|
||||||
|
key={navCategory.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Иконка */}
|
||||||
|
<div className="w-12 h-12 border rounded-lg flex items-center justify-center overflow-hidden">
|
||||||
|
{navCategory.icon ? (
|
||||||
|
<img
|
||||||
|
src={navCategory.icon}
|
||||||
|
alt={navCategory.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="h-6 w-6 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Информация */}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{navCategory.name}</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{getCategoryPath(navCategory.partsIndexCatalogId, navCategory.partsIndexGroupId)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Сортировка: {navCategory.sortOrder}
|
||||||
|
</Badge>
|
||||||
|
{navCategory.isHidden && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
<EyeOff className="h-3 w-3 mr-1" />
|
||||||
|
Скрыта
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!navCategory.isHidden && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
Видима
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Действия */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(navCategory)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(navCategory)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Инструкции */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Как использовать</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
<strong>1. Выберите каталог:</strong> Из списка каталогов PartsIndex выберите тот, для которого хотите добавить иконку.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>2. Выберите группу (необязательно):</strong> Если хотите добавить иконку для конкретной группы внутри каталога, выберите её. Иначе иконка будет применена ко всему каталогу.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>3. Загрузите иконку:</strong> Выберите небольшое изображение (рекомендуется 32x32 пикселя) которое будет отображаться в навигации сайта.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>4. Настройте отображение:</strong> Установите порядок сортировки и видимость категории в навигации.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -34,6 +34,11 @@ const navigationItems = [
|
|||||||
href: '/dashboard/catalog',
|
href: '/dashboard/catalog',
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Навигация сайта',
|
||||||
|
href: '/dashboard/navigation',
|
||||||
|
icon: Star,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Товары главной',
|
title: 'Товары главной',
|
||||||
href: '/dashboard/homepage-products',
|
href: '/dashboard/homepage-products',
|
||||||
|
@ -137,6 +137,7 @@ export const CREATE_CATEGORY = gql`
|
|||||||
seoTitle
|
seoTitle
|
||||||
seoDescription
|
seoDescription
|
||||||
image
|
image
|
||||||
|
icon
|
||||||
isHidden
|
isHidden
|
||||||
includeSubcategoryProducts
|
includeSubcategoryProducts
|
||||||
parentId
|
parentId
|
||||||
@ -160,6 +161,7 @@ export const UPDATE_CATEGORY = gql`
|
|||||||
seoTitle
|
seoTitle
|
||||||
seoDescription
|
seoDescription
|
||||||
image
|
image
|
||||||
|
icon
|
||||||
isHidden
|
isHidden
|
||||||
includeSubcategoryProducts
|
includeSubcategoryProducts
|
||||||
parentId
|
parentId
|
||||||
@ -179,6 +181,49 @@ export const DELETE_CATEGORY = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Навигационные категории
|
||||||
|
export const CREATE_NAVIGATION_CATEGORY = gql`
|
||||||
|
mutation CreateNavigationCategory($input: NavigationCategoryInput!) {
|
||||||
|
createNavigationCategory(input: $input) {
|
||||||
|
id
|
||||||
|
partsIndexCatalogId
|
||||||
|
partsIndexGroupId
|
||||||
|
icon
|
||||||
|
isHidden
|
||||||
|
sortOrder
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
name
|
||||||
|
catalogName
|
||||||
|
groupName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const UPDATE_NAVIGATION_CATEGORY = gql`
|
||||||
|
mutation UpdateNavigationCategory($id: ID!, $input: NavigationCategoryInput!) {
|
||||||
|
updateNavigationCategory(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
partsIndexCatalogId
|
||||||
|
partsIndexGroupId
|
||||||
|
icon
|
||||||
|
isHidden
|
||||||
|
sortOrder
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
name
|
||||||
|
catalogName
|
||||||
|
groupName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const DELETE_NAVIGATION_CATEGORY = gql`
|
||||||
|
mutation DeleteNavigationCategory($id: ID!) {
|
||||||
|
deleteNavigationCategory(id: $id)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const DELETE_PRODUCTS = gql`
|
export const DELETE_PRODUCTS = gql`
|
||||||
mutation DeleteProducts($ids: [ID!]!) {
|
mutation DeleteProducts($ids: [ID!]!) {
|
||||||
deleteProducts(ids: $ids) {
|
deleteProducts(ids: $ids) {
|
||||||
|
@ -1401,4 +1401,70 @@ export const GET_PAYMENT = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Навигационные категории
|
||||||
|
export const GET_NAVIGATION_CATEGORIES = gql`
|
||||||
|
query GetNavigationCategories {
|
||||||
|
navigationCategories {
|
||||||
|
id
|
||||||
|
partsIndexCatalogId
|
||||||
|
partsIndexGroupId
|
||||||
|
icon
|
||||||
|
isHidden
|
||||||
|
sortOrder
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
name
|
||||||
|
catalogName
|
||||||
|
groupName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_NAVIGATION_CATEGORY = gql`
|
||||||
|
query GetNavigationCategory($id: ID!) {
|
||||||
|
navigationCategory(id: $id) {
|
||||||
|
id
|
||||||
|
partsIndexCatalogId
|
||||||
|
partsIndexGroupId
|
||||||
|
icon
|
||||||
|
isHidden
|
||||||
|
sortOrder
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
name
|
||||||
|
catalogName
|
||||||
|
groupName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// PartsIndex категории автотоваров
|
||||||
|
export const GET_PARTSINDEX_CATEGORIES = gql`
|
||||||
|
query GetPartsIndexCategories($lang: String) {
|
||||||
|
partsIndexCategoriesWithGroups(lang: $lang) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
image
|
||||||
|
groups {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
image
|
||||||
|
subgroups {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
image
|
||||||
|
entityNames {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entityNames {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
@ -132,6 +132,15 @@ interface CategoryInput {
|
|||||||
parentId?: string
|
parentId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Интерфейсы для навигационных категорий
|
||||||
|
interface NavigationCategoryInput {
|
||||||
|
partsIndexCatalogId: string
|
||||||
|
partsIndexGroupId?: string
|
||||||
|
icon?: string
|
||||||
|
isHidden?: boolean
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductInput {
|
interface ProductInput {
|
||||||
name: string
|
name: string
|
||||||
slug?: string
|
slug?: string
|
||||||
@ -1870,6 +1879,90 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Навигационные категории
|
||||||
|
navigationCategories: async () => {
|
||||||
|
try {
|
||||||
|
const categories = await prisma.navigationCategory.findMany({
|
||||||
|
where: { isHidden: false },
|
||||||
|
orderBy: { sortOrder: 'asc' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем данные из PartsIndex для каждой категории
|
||||||
|
const categoriesWithData = await Promise.all(
|
||||||
|
categories.map(async (category) => {
|
||||||
|
try {
|
||||||
|
// Получаем каталоги PartsIndex
|
||||||
|
const catalogs = await partsIndexService.getCatalogs('ru')
|
||||||
|
const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
|
||||||
|
|
||||||
|
let groupName = null
|
||||||
|
|
||||||
|
// Если есть groupId, получаем группы
|
||||||
|
if (category.partsIndexGroupId && catalog) {
|
||||||
|
const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
|
||||||
|
const group = groups.find(g => g.id === category.partsIndexGroupId)
|
||||||
|
groupName = group?.name || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
name: groupName || catalog?.name || 'Неизвестная категория',
|
||||||
|
catalogName: catalog?.name || 'Неизвестный каталог',
|
||||||
|
groupName
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения данных PartsIndex для категории:', category.id, error)
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
name: 'Ошибка загрузки',
|
||||||
|
catalogName: 'Ошибка загрузки',
|
||||||
|
groupName: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return categoriesWithData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения навигационных категорий:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
navigationCategory: async (_: unknown, { id }: { id: string }) => {
|
||||||
|
try {
|
||||||
|
const category = await prisma.navigationCategory.findUnique({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new Error('Навигационная категория не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем данные из PartsIndex
|
||||||
|
const catalogs = await partsIndexService.getCatalogs('ru')
|
||||||
|
const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
|
||||||
|
|
||||||
|
let groupName = null
|
||||||
|
|
||||||
|
if (category.partsIndexGroupId && catalog) {
|
||||||
|
const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
|
||||||
|
const group = groups.find(g => g.id === category.partsIndexGroupId)
|
||||||
|
groupName = group?.name || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
name: groupName || catalog?.name || 'Неизвестная категория',
|
||||||
|
catalogName: catalog?.name || 'Неизвестный каталог',
|
||||||
|
groupName
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка получения навигационной категории:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
laximoUnits: async (_: unknown, { catalogCode, vehicleId, ssd, categoryId }: { catalogCode: string; vehicleId?: string; ssd?: string; categoryId?: string }) => {
|
laximoUnits: async (_: unknown, { catalogCode, vehicleId, ssd, categoryId }: { catalogCode: string; vehicleId?: string; ssd?: string; categoryId?: string }) => {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 GraphQL Resolver - запрос узлов каталога:', {
|
console.log('🔍 GraphQL Resolver - запрос узлов каталога:', {
|
||||||
@ -3080,6 +3173,67 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Получить параметры каталога PartsIndex для фильтрации
|
||||||
|
partsIndexCatalogParams: async (_: unknown, {
|
||||||
|
catalogId,
|
||||||
|
groupId,
|
||||||
|
lang = 'ru',
|
||||||
|
engineId,
|
||||||
|
generationId,
|
||||||
|
params,
|
||||||
|
q
|
||||||
|
}: {
|
||||||
|
catalogId: string;
|
||||||
|
groupId: string;
|
||||||
|
lang?: 'ru' | 'en';
|
||||||
|
engineId?: string;
|
||||||
|
generationId?: string;
|
||||||
|
params?: string;
|
||||||
|
q?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
console.log('🔍 GraphQL resolver partsIndexCatalogParams вызван с параметрами:', {
|
||||||
|
catalogId,
|
||||||
|
groupId,
|
||||||
|
lang,
|
||||||
|
q
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем строку params в объект если передан
|
||||||
|
let parsedParams: Record<string, any> | undefined;
|
||||||
|
if (params) {
|
||||||
|
try {
|
||||||
|
parsedParams = JSON.parse(params);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Не удалось разобрать параметры фильтрации:', params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramsData = await partsIndexService.getCatalogParams(catalogId, groupId, {
|
||||||
|
lang,
|
||||||
|
engineId,
|
||||||
|
generationId,
|
||||||
|
params: parsedParams,
|
||||||
|
q
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!paramsData) {
|
||||||
|
console.warn('⚠️ Не удалось получить параметры каталога')
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
paramsQuery: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Получены параметры каталога:', paramsData.list.length)
|
||||||
|
|
||||||
|
return paramsData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogParams:', error)
|
||||||
|
throw new Error('Не удалось получить параметры каталога')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// PartsAPI артикулы
|
// PartsAPI артикулы
|
||||||
partsAPIArticles: async (_: unknown, { strId, carId, carType = 'PC' }: { strId: number; carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => {
|
partsAPIArticles: async (_: unknown, { strId, carId, carType = 'PC' }: { strId: number; carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => {
|
||||||
try {
|
try {
|
||||||
@ -4442,6 +4596,223 @@ export const resolvers = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Навигационные категории
|
||||||
|
createNavigationCategory: async (_: unknown, { input }: { input: NavigationCategoryInput }, context: Context) => {
|
||||||
|
try {
|
||||||
|
if (!context.userId) {
|
||||||
|
throw new Error('Пользователь не авторизован')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что такой комбинации еще нет
|
||||||
|
const existing = await prisma.navigationCategory.findFirst({
|
||||||
|
where: {
|
||||||
|
partsIndexCatalogId: input.partsIndexCatalogId,
|
||||||
|
partsIndexGroupId: input.partsIndexGroupId ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Иконка для этой категории уже существует')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем иконку в S3 если есть
|
||||||
|
let iconUrl: string | null = null
|
||||||
|
if (input.icon) {
|
||||||
|
try {
|
||||||
|
const iconData = input.icon.replace(/^data:image\/[a-z]+;base64,/, '')
|
||||||
|
const buffer = Buffer.from(iconData, 'base64')
|
||||||
|
|
||||||
|
const fileKey = generateFileKey('navigation-icons', 'png')
|
||||||
|
const uploadResult = await uploadBuffer(buffer, fileKey, 'image/png')
|
||||||
|
iconUrl = uploadResult.url
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки иконки:', error)
|
||||||
|
throw new Error('Не удалось загрузить иконку')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await prisma.navigationCategory.create({
|
||||||
|
data: {
|
||||||
|
partsIndexCatalogId: input.partsIndexCatalogId,
|
||||||
|
partsIndexGroupId: input.partsIndexGroupId ?? null,
|
||||||
|
icon: iconUrl,
|
||||||
|
isHidden: input.isHidden || false,
|
||||||
|
sortOrder: input.sortOrder || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем данные из PartsIndex для ответа
|
||||||
|
const catalogs = await partsIndexService.getCatalogs('ru')
|
||||||
|
const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
|
||||||
|
|
||||||
|
let groupName = null
|
||||||
|
if (category.partsIndexGroupId && catalog) {
|
||||||
|
const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
|
||||||
|
const group = groups.find(g => g.id === category.partsIndexGroupId)
|
||||||
|
groupName = group?.name || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...category,
|
||||||
|
name: groupName || catalog?.name || 'Неизвестная категория',
|
||||||
|
catalogName: catalog?.name || 'Неизвестный каталог',
|
||||||
|
groupName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
if (context.headers) {
|
||||||
|
const { ipAddress, userAgent } = getClientInfo(context.headers)
|
||||||
|
await createAuditLog({
|
||||||
|
userId: context.userId,
|
||||||
|
action: AuditAction.CATEGORY_CREATE,
|
||||||
|
details: `Навигационная категория: ${result.name}`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка создания навигационной категории:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNavigationCategory: async (_: unknown, { id, input }: { id: string; input: NavigationCategoryInput }, context: Context) => {
|
||||||
|
try {
|
||||||
|
if (!context.userId) {
|
||||||
|
throw new Error('Пользователь не авторизован')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCategory = await prisma.navigationCategory.findUnique({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingCategory) {
|
||||||
|
throw new Error('Навигационная категория не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем уникальность если изменяются partsIndex поля
|
||||||
|
if (input.partsIndexCatalogId || input.partsIndexGroupId !== undefined) {
|
||||||
|
const catalogId = input.partsIndexCatalogId || existingCategory.partsIndexCatalogId
|
||||||
|
const groupId = input.partsIndexGroupId !== undefined ? input.partsIndexGroupId : existingCategory.partsIndexGroupId
|
||||||
|
|
||||||
|
const conflicting = await prisma.navigationCategory.findFirst({
|
||||||
|
where: {
|
||||||
|
partsIndexCatalogId: catalogId,
|
||||||
|
partsIndexGroupId: groupId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (conflicting && conflicting.id !== id) {
|
||||||
|
throw new Error('Иконка для этой категории уже существует')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем новую иконку если есть
|
||||||
|
let iconUrl = existingCategory.icon
|
||||||
|
if (input.icon) {
|
||||||
|
try {
|
||||||
|
const iconData = input.icon.replace(/^data:image\/[a-z]+;base64,/, '')
|
||||||
|
const buffer = Buffer.from(iconData, 'base64')
|
||||||
|
|
||||||
|
const fileKey = generateFileKey('navigation-icons', 'png')
|
||||||
|
const uploadResult = await uploadBuffer(buffer, fileKey, 'image/png')
|
||||||
|
iconUrl = uploadResult.url
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки иконки:', error)
|
||||||
|
throw new Error('Не удалось загрузить иконку')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await prisma.navigationCategory.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
partsIndexCatalogId: input.partsIndexCatalogId || existingCategory.partsIndexCatalogId,
|
||||||
|
partsIndexGroupId: input.partsIndexGroupId !== undefined ? (input.partsIndexGroupId ?? null) : existingCategory.partsIndexGroupId,
|
||||||
|
icon: iconUrl,
|
||||||
|
isHidden: input.isHidden !== undefined ? input.isHidden : existingCategory.isHidden,
|
||||||
|
sortOrder: input.sortOrder !== undefined ? input.sortOrder : existingCategory.sortOrder
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получаем данные из PartsIndex для ответа
|
||||||
|
const catalogs = await partsIndexService.getCatalogs('ru')
|
||||||
|
const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId)
|
||||||
|
|
||||||
|
let groupName = null
|
||||||
|
if (category.partsIndexGroupId && catalog) {
|
||||||
|
const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru')
|
||||||
|
const group = groups.find(g => g.id === category.partsIndexGroupId)
|
||||||
|
groupName = group?.name || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...category,
|
||||||
|
name: groupName || catalog?.name || 'Неизвестная категория',
|
||||||
|
catalogName: catalog?.name || 'Неизвестный каталог',
|
||||||
|
groupName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
if (context.headers) {
|
||||||
|
const { ipAddress, userAgent } = getClientInfo(context.headers)
|
||||||
|
await createAuditLog({
|
||||||
|
userId: context.userId,
|
||||||
|
action: AuditAction.CATEGORY_UPDATE,
|
||||||
|
details: `Навигационная категория: ${result.name}`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка обновления навигационной категории:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteNavigationCategory: async (_: unknown, { id }: { id: string }, context: Context) => {
|
||||||
|
try {
|
||||||
|
if (!context.userId) {
|
||||||
|
throw new Error('Пользователь не авторизован')
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await prisma.navigationCategory.findUnique({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new Error('Навигационная категория не найдена')
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.navigationCategory.delete({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Логируем действие
|
||||||
|
if (context.headers) {
|
||||||
|
const { ipAddress, userAgent } = getClientInfo(context.headers)
|
||||||
|
await createAuditLog({
|
||||||
|
userId: context.userId,
|
||||||
|
action: AuditAction.CATEGORY_DELETE,
|
||||||
|
details: `Навигационная категория ID: ${category.id}`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка удаления навигационной категории:', error)
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw new Error('Не удалось удалить навигационную категорию')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Товары
|
// Товары
|
||||||
createProduct: async (_: unknown, {
|
createProduct: async (_: unknown, {
|
||||||
input,
|
input,
|
||||||
|
@ -73,6 +73,22 @@ export const typeDefs = gql`
|
|||||||
updatedAt: DateTime!
|
updatedAt: DateTime!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NavigationCategory {
|
||||||
|
id: ID!
|
||||||
|
partsIndexCatalogId: String!
|
||||||
|
partsIndexGroupId: String
|
||||||
|
icon: String
|
||||||
|
isHidden: Boolean!
|
||||||
|
sortOrder: Int!
|
||||||
|
createdAt: DateTime!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
|
||||||
|
# Виртуальные поля - получаем данные из PartsIndex API
|
||||||
|
name: String!
|
||||||
|
catalogName: String!
|
||||||
|
groupName: String
|
||||||
|
}
|
||||||
|
|
||||||
type Product {
|
type Product {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
@ -600,6 +616,14 @@ export const typeDefs = gql`
|
|||||||
parentId: String
|
parentId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input NavigationCategoryInput {
|
||||||
|
partsIndexCatalogId: String!
|
||||||
|
partsIndexGroupId: String
|
||||||
|
icon: String
|
||||||
|
isHidden: Boolean
|
||||||
|
sortOrder: Int
|
||||||
|
}
|
||||||
|
|
||||||
input ProductInput {
|
input ProductInput {
|
||||||
name: String!
|
name: String!
|
||||||
slug: String
|
slug: String
|
||||||
@ -924,6 +948,10 @@ export const typeDefs = gql`
|
|||||||
laximoQuickGroups(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
|
laximoQuickGroups(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
|
||||||
laximoQuickGroupsWithXML(catalogCode: String!, vehicleId: String, ssd: String): LaximoQuickGroupsResponse!
|
laximoQuickGroupsWithXML(catalogCode: String!, vehicleId: String, ssd: String): LaximoQuickGroupsResponse!
|
||||||
laximoCategories(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
|
laximoCategories(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
|
||||||
|
|
||||||
|
# Навигационные категории
|
||||||
|
navigationCategories: [NavigationCategory!]!
|
||||||
|
navigationCategory(id: ID!): NavigationCategory
|
||||||
laximoUnits(catalogCode: String!, vehicleId: String, ssd: String, categoryId: String): [LaximoQuickGroup!]!
|
laximoUnits(catalogCode: String!, vehicleId: String, ssd: String, categoryId: String): [LaximoQuickGroup!]!
|
||||||
laximoQuickDetail(catalogCode: String!, vehicleId: String!, quickGroupId: String!, ssd: String!): LaximoQuickDetail
|
laximoQuickDetail(catalogCode: String!, vehicleId: String!, quickGroupId: String!, ssd: String!): LaximoQuickDetail
|
||||||
laximoOEMSearch(catalogCode: String!, vehicleId: String!, oemNumber: String!, ssd: String!): LaximoOEMResult
|
laximoOEMSearch(catalogCode: String!, vehicleId: String!, oemNumber: String!, ssd: String!): LaximoOEMResult
|
||||||
@ -1088,6 +1116,11 @@ export const typeDefs = gql`
|
|||||||
updateCategory(id: ID!, input: CategoryInput!): Category!
|
updateCategory(id: ID!, input: CategoryInput!): Category!
|
||||||
deleteCategory(id: ID!): Boolean!
|
deleteCategory(id: ID!): Boolean!
|
||||||
|
|
||||||
|
# Навигационные категории
|
||||||
|
createNavigationCategory(input: NavigationCategoryInput!): NavigationCategory!
|
||||||
|
updateNavigationCategory(id: ID!, input: NavigationCategoryInput!): NavigationCategory!
|
||||||
|
deleteNavigationCategory(id: ID!): Boolean!
|
||||||
|
|
||||||
# Товары
|
# Товары
|
||||||
createProduct(input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
|
createProduct(input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
|
||||||
updateProduct(id: ID!, input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
|
updateProduct(id: ID!, input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Интерфейсы для типизации данных Parts Index API
|
||||||
export interface PartsIndexCatalog {
|
export interface PartsIndexCatalog {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string | null;
|
image: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartsIndexGroup {
|
export interface PartsIndexGroup {
|
||||||
@ -14,10 +15,6 @@ export interface PartsIndexGroup {
|
|||||||
entityNames: { id: string; name: string; }[];
|
entityNames: { id: string; name: string; }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartsIndexCatalogsResponse {
|
|
||||||
list: PartsIndexCatalog[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PartsIndexGroupResponse {
|
export interface PartsIndexGroupResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -29,35 +26,30 @@ export interface PartsIndexGroupResponse {
|
|||||||
subgroups: PartsIndexGroup[];
|
subgroups: PartsIndexGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Новые интерфейсы для товаров каталога
|
|
||||||
export interface PartsIndexParameter {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
values: Array<{
|
|
||||||
id: string;
|
|
||||||
value: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PartsIndexBrand {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PartsIndexProductName {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PartsIndexEntity {
|
export interface PartsIndexEntity {
|
||||||
id: string;
|
id: string;
|
||||||
name: PartsIndexProductName;
|
|
||||||
originalName: string;
|
|
||||||
code: string;
|
code: string;
|
||||||
brand: PartsIndexBrand;
|
name: {
|
||||||
parameters: PartsIndexParameter[];
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
originalName: string;
|
||||||
|
brand: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
barcodes: string[];
|
||||||
|
parameters: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
values: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
title?: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
images: string[];
|
images: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +66,8 @@ export interface PartsIndexEntitiesResponse {
|
|||||||
catalog: {
|
catalog: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
image: string;
|
||||||
|
groups: PartsIndexGroup[];
|
||||||
};
|
};
|
||||||
subgroup: {
|
subgroup: {
|
||||||
id: string;
|
id: string;
|
||||||
@ -82,24 +76,22 @@ export interface PartsIndexEntitiesResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PartsIndexParamsResponse {
|
export interface PartsIndexParamsResponse {
|
||||||
list: PartsIndexParam[];
|
list: {
|
||||||
paramsQuery: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PartsIndexParam {
|
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
isGeneral: boolean;
|
code: string;
|
||||||
type: 'select' | 'range';
|
type: 'range' | 'dropdown';
|
||||||
values: PartsIndexParamValue[];
|
values: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
title?: string;
|
||||||
|
available: boolean;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartsIndexParamValue {
|
export interface PartsIndexEntityInfoResponse {
|
||||||
value: string;
|
list: PartsIndexEntityDetail[];
|
||||||
title: string;
|
|
||||||
available: boolean;
|
|
||||||
selected: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartsIndexEntityDetail {
|
export interface PartsIndexEntityDetail {
|
||||||
@ -155,20 +147,99 @@ export interface PartsIndexEntityDetail {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Интерфейс для кэша
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
ttl: number; // время жизни в миллисекундах
|
||||||
|
}
|
||||||
|
|
||||||
class PartsIndexService {
|
class PartsIndexService {
|
||||||
private baseURL = 'https://api.parts-index.com/v1';
|
private baseURL = 'https://api.parts-index.com/v1';
|
||||||
private apiKey: string;
|
private apiKey = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
|
||||||
|
|
||||||
constructor() {
|
// Простой in-memory кэш
|
||||||
this.apiKey = process.env.PARTSINDEX_API_KEY || '';
|
private cache = new Map<string, CacheEntry<any>>();
|
||||||
|
private readonly DEFAULT_TTL = 30 * 60 * 1000; // 30 минут
|
||||||
|
private readonly CATALOGS_TTL = 24 * 60 * 60 * 1000; // 24 часа для каталогов
|
||||||
|
private readonly GROUPS_TTL = 24 * 60 * 60 * 1000; // 24 часа для групп
|
||||||
|
private readonly ENTITIES_TTL = 10 * 60 * 1000; // 10 минут для товаров
|
||||||
|
private readonly PARAMS_TTL = 60 * 60 * 1000; // 1 час для параметров
|
||||||
|
|
||||||
if (!this.apiKey) {
|
// Проверяем актуальность кэша
|
||||||
console.error('❌ PartsIndex API ключ не найден в переменных окружения');
|
private isValidCacheEntry<T>(entry: CacheEntry<T>): boolean {
|
||||||
|
return Date.now() - entry.timestamp < entry.ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получаем данные из кэша
|
||||||
|
private getFromCache<T>(key: string): T | null {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (entry && this.isValidCacheEntry(entry)) {
|
||||||
|
console.log(`🔥 Используем кэш для ключа: ${key}`);
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
if (entry) {
|
||||||
|
console.log(`🗑️ Удаляем устаревший кэш для ключа: ${key}`);
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем данные в кэш
|
||||||
|
private setCache<T>(key: string, data: T, ttl: number = this.DEFAULT_TTL): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
ttl
|
||||||
|
});
|
||||||
|
console.log(`💾 Сохранено в кэш: ${key} (TTL: ${ttl}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка кэша (для административных целей)
|
||||||
|
public clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
console.log('🗑️ Кэш PartsIndex полностью очищен');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очистка конкретного типа кэша
|
||||||
|
public clearCacheByPrefix(prefix: string): void {
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
this.cache.forEach((_, key) => {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
keysToDelete.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
keysToDelete.forEach(key => this.cache.delete(key));
|
||||||
|
console.log(`🗑️ Очищен кэш PartsIndex с префиксом: ${prefix} (${keysToDelete.length} записей)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статистика кэша
|
||||||
|
public getCacheStats(): { size: number; entries: { key: string; size: number; ttl: number; age: number }[] } {
|
||||||
|
const entries: { key: string; size: number; ttl: number; age: number }[] = [];
|
||||||
|
|
||||||
|
this.cache.forEach((entry, key) => {
|
||||||
|
const size = JSON.stringify(entry.data).length;
|
||||||
|
const age = Date.now() - entry.timestamp;
|
||||||
|
entries.push({ key, size, ttl: entry.ttl, age });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: this.cache.size,
|
||||||
|
entries: entries.sort((a, b) => b.size - a.size) // Сортируем по размеру
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить список каталогов
|
// Получить список каталогов
|
||||||
async getCatalogs(lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexCatalog[]> {
|
async getCatalogs(lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexCatalog[]> {
|
||||||
|
const cacheKey = `catalogs_${lang}`;
|
||||||
|
|
||||||
|
// Проверяем кэш
|
||||||
|
const cached = this.getFromCache<PartsIndexCatalog[]>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🔍 PartsIndex запрос каталогов:', { lang });
|
console.log('🔍 PartsIndex запрос каталогов:', { lang });
|
||||||
|
|
||||||
@ -187,7 +258,11 @@ class PartsIndexService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data.list;
|
const catalogs = response.data.list;
|
||||||
|
// Сохраняем в кэш на 1 час
|
||||||
|
this.setCache(cacheKey, catalogs, this.CATALOGS_TTL);
|
||||||
|
|
||||||
|
return catalogs;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Ошибка запроса PartsIndex getCatalogs:', error);
|
console.error('❌ Ошибка запроса PartsIndex getCatalogs:', error);
|
||||||
return [];
|
return [];
|
||||||
@ -196,6 +271,14 @@ class PartsIndexService {
|
|||||||
|
|
||||||
// Получить группы каталога
|
// Получить группы каталога
|
||||||
async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexGroup[]> {
|
async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexGroup[]> {
|
||||||
|
const cacheKey = `groups_${catalogId}_${lang}`;
|
||||||
|
|
||||||
|
// Проверяем кэш
|
||||||
|
const cached = this.getFromCache<PartsIndexGroup[]>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🔍 PartsIndex запрос групп каталога:', { catalogId, lang });
|
console.log('🔍 PartsIndex запрос групп каталога:', { catalogId, lang });
|
||||||
|
|
||||||
@ -218,21 +301,28 @@ class PartsIndexService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let groups: PartsIndexGroup[];
|
||||||
|
|
||||||
// Если есть подгруппы, возвращаем их
|
// Если есть подгруппы, возвращаем их
|
||||||
if (groupData.subgroups.length > 0) {
|
if (groupData.subgroups.length > 0) {
|
||||||
console.log('📁 Найдено подгрупп:', groupData.subgroups.length);
|
console.log('📁 Найдено подгрупп:', groupData.subgroups.length);
|
||||||
return groupData.subgroups;
|
groups = groupData.subgroups;
|
||||||
}
|
} else {
|
||||||
|
|
||||||
// Если подгрупп нет, создаем группу из самого каталога
|
// Если подгрупп нет, создаем группу из самого каталога
|
||||||
console.log('📝 Подгрупп нет, возвращаем главную группу');
|
console.log('📝 Подгрупп нет, возвращаем главную группу');
|
||||||
return [{
|
groups = [{
|
||||||
id: groupData.id,
|
id: groupData.id,
|
||||||
name: groupData.name,
|
name: groupData.name,
|
||||||
image: groupData.image,
|
image: groupData.image,
|
||||||
subgroups: [],
|
subgroups: [],
|
||||||
entityNames: groupData.entityNames
|
entityNames: groupData.entityNames
|
||||||
}];
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в кэш на 24 часа
|
||||||
|
this.setCache(cacheKey, groups, this.GROUPS_TTL);
|
||||||
|
|
||||||
|
return groups;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Ошибка запроса PartsIndex getCatalogGroups:', error);
|
console.error('❌ Ошибка запроса PartsIndex getCatalogGroups:', error);
|
||||||
return [];
|
return [];
|
||||||
@ -253,7 +343,6 @@ class PartsIndexService {
|
|||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<PartsIndexEntitiesResponse | null> {
|
): Promise<PartsIndexEntitiesResponse | null> {
|
||||||
try {
|
|
||||||
const {
|
const {
|
||||||
lang = 'ru',
|
lang = 'ru',
|
||||||
limit = 25,
|
limit = 25,
|
||||||
@ -264,6 +353,16 @@ class PartsIndexService {
|
|||||||
params
|
params
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
// Создаем ключ кэша на основе всех параметров
|
||||||
|
const cacheKey = `entities_${catalogId}_${groupId}_${lang}_${limit}_${page}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`;
|
||||||
|
|
||||||
|
// Проверяем кэш (кэшируем товары на короткое время)
|
||||||
|
const cached = this.getFromCache<PartsIndexEntitiesResponse>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
console.log('🔍 PartsIndex запрос товаров каталога:', {
|
console.log('🔍 PartsIndex запрос товаров каталога:', {
|
||||||
catalogId,
|
catalogId,
|
||||||
groupId,
|
groupId,
|
||||||
@ -316,7 +415,11 @@ class PartsIndexService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
const result = response.data;
|
||||||
|
// Сохраняем в кэш на 10 минут (товары могут изменяться)
|
||||||
|
this.setCache(cacheKey, result, this.ENTITIES_TTL);
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Ошибка запроса PartsIndex getCatalogEntities:', error);
|
console.error('❌ Ошибка запроса PartsIndex getCatalogEntities:', error);
|
||||||
return null;
|
return null;
|
||||||
@ -335,7 +438,6 @@ class PartsIndexService {
|
|||||||
q?: string;
|
q?: string;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<PartsIndexParamsResponse | null> {
|
): Promise<PartsIndexParamsResponse | null> {
|
||||||
try {
|
|
||||||
const {
|
const {
|
||||||
lang = 'ru',
|
lang = 'ru',
|
||||||
engineId,
|
engineId,
|
||||||
@ -344,6 +446,16 @@ class PartsIndexService {
|
|||||||
q
|
q
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
// Создаем ключ кэша на основе всех параметров
|
||||||
|
const cacheKey = `params_${catalogId}_${groupId}_${lang}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`;
|
||||||
|
|
||||||
|
// Проверяем кэш
|
||||||
|
const cached = this.getFromCache<PartsIndexParamsResponse>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
console.log('🔍 PartsIndex запрос параметров каталога:', {
|
console.log('🔍 PartsIndex запрос параметров каталога:', {
|
||||||
catalogId,
|
catalogId,
|
||||||
groupId,
|
groupId,
|
||||||
@ -392,15 +504,27 @@ class PartsIndexService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
const result = response.data;
|
||||||
|
// Сохраняем в кэш на 1 час
|
||||||
|
this.setCache(cacheKey, result, this.PARAMS_TTL);
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Ошибка запроса PartsIndex getCatalogParams:', error);
|
console.error('❌ Ошибка запроса PartsIndex getCatalogParams:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить полную структуру категорий с подкатегориями
|
// Получить полную структуру категорий с подкатегориями (оптимизированная версия)
|
||||||
async getCategoriesWithGroups(lang: 'ru' | 'en' = 'ru'): Promise<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>> {
|
async getCategoriesWithGroups(lang: 'ru' | 'en' = 'ru'): Promise<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>> {
|
||||||
|
const cacheKey = `categories_with_groups_${lang}`;
|
||||||
|
|
||||||
|
// Проверяем кэш
|
||||||
|
const cached = this.getFromCache<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🔍 PartsIndex запрос полной структуры категорий');
|
console.log('🔍 PartsIndex запрос полной структуры категорий');
|
||||||
|
|
||||||
@ -413,17 +537,43 @@ class PartsIndexService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Для каждого каталога получаем его группы
|
// Для каждого каталога получаем его группы
|
||||||
const catalogsWithGroups = await Promise.all(
|
// Ограничиваем количество одновременных запросов
|
||||||
catalogs.map(async (catalog) => {
|
const BATCH_SIZE = 3;
|
||||||
|
const catalogsWithGroups: Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < catalogs.length; i += BATCH_SIZE) {
|
||||||
|
const batch = catalogs.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
batch.map(async (catalog) => {
|
||||||
|
try {
|
||||||
const groups = await this.getCatalogGroups(catalog.id, lang);
|
const groups = await this.getCatalogGroups(catalog.id, lang);
|
||||||
return {
|
return {
|
||||||
...catalog,
|
...catalog,
|
||||||
groups
|
groups
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Ошибка загрузки групп для каталога ${catalog.id}:`, error);
|
||||||
|
return {
|
||||||
|
...catalog,
|
||||||
|
groups: []
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('✅ PartsIndex полная структуря получена:', catalogsWithGroups.length, 'каталогов');
|
catalogsWithGroups.push(...batchResults);
|
||||||
|
|
||||||
|
// Небольшая задержка между батчами для снижения нагрузки на API
|
||||||
|
if (i + BATCH_SIZE < catalogs.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ PartsIndex полная структура получена:', catalogsWithGroups.length, 'каталогов');
|
||||||
|
|
||||||
|
// Сохраняем в кэш на 24 часа
|
||||||
|
this.setCache(cacheKey, catalogsWithGroups, this.CATALOGS_TTL);
|
||||||
|
|
||||||
return catalogsWithGroups;
|
return catalogsWithGroups;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
Reference in New Issue
Block a user