This commit is contained in:
54CHA
2025-07-29 18:55:09 +03:00
parent 94ed190869
commit 08f76a7633
14 changed files with 2869 additions and 16 deletions

159
package-lock.json generated
View File

@ -34,6 +34,7 @@
"@types/jsonwebtoken": "^9.0.9",
"@types/jspdf": "^1.3.3",
"@types/pdfkit": "^0.14.0",
"@types/pg": "^8.15.4",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"axios": "^1.10.0",
@ -55,6 +56,7 @@
"lucide-react": "^0.513.0",
"next": "15.3.3",
"pdfkit": "^0.17.1",
"pg": "^8.16.3",
"postcss": "^8.5.6",
"prisma": "^6.9.0",
"puppeteer": "^24.10.2",
@ -4834,6 +4836,17 @@
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.15.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
"integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
@ -10766,6 +10779,95 @@
"license": "MIT",
"optional": true
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -10837,6 +10939,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -11914,6 +12055,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@ -13168,6 +13318,15 @@
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",

View File

@ -44,6 +44,7 @@
"@types/jsonwebtoken": "^9.0.9",
"@types/jspdf": "^1.3.3",
"@types/pdfkit": "^0.14.0",
"@types/pg": "^8.15.4",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"axios": "^1.10.0",
@ -65,6 +66,7 @@
"lucide-react": "^0.513.0",
"next": "15.3.3",
"pdfkit": "^0.17.1",
"pg": "^8.16.3",
"postcss": "^8.5.6",
"prisma": "^6.9.0",
"puppeteer": "^24.10.2",

View File

@ -797,6 +797,43 @@ enum DiscountCodeType {
PROMOCODE
}
// Cart models for backend cart storage
model Cart {
id String @id @default(cuid())
clientId String @unique // Can be authenticated client ID or anonymous session ID
items CartItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("carts")
}
model CartItem {
id String @id @default(cuid())
cartId String
productId String? // For internal products
offerKey String? // For external products (AutoEuro)
name String
description String
brand String
article String
price Float
currency String @default("RUB")
quantity Int
stock Int?
deliveryTime String?
warehouse String?
supplier String?
isExternal Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
@@map("cart_items")
}
enum DeliveryType {
COURIER
PICKUP

View File

@ -0,0 +1,332 @@
"use client"
import { useState } from 'react'
import { useQuery } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import {
Search,
Shield,
Package,
Loader2,
ChevronRight,
Eye
} from 'lucide-react'
import { GET_PARTSINDEX_CATEGORIES, GET_PARTSAPI_CATEGORIES } from '@/lib/graphql/queries'
import { KrajaCategories } from '@/components/kraja/KrajaCategories'
import { KrajaCategoryItems } from '@/components/kraja/KrajaCategoryItems'
import { KrajaSavedTables } from '@/components/kraja/KrajaSavedTables'
interface Category {
id: string
name: string
image?: string
groups?: Array<{
id: string
name: string
image?: string
subgroups?: Array<{
id: string
name: string
image?: string
entityNames?: Array<{
id: string
name: string
}>
}>
entityNames?: Array<{
id: string
name: string
}>
}>
}
interface PartsAPICategory {
id: string
name: string
level: number
parentId?: string
children?: PartsAPICategory[]
}
export default function KrajaPage() {
const [activeTab, setActiveTab] = useState<'partsindex' | 'partsapi' | 'saved'>('partsindex')
const [selectedCategory, setSelectedCategory] = useState<Category | PartsAPICategory | null>(null)
const [selectedGroup, setSelectedGroup] = useState<any>(null)
const [searchQuery, setSearchQuery] = useState('')
const [viewingTable, setViewingTable] = useState<{categoryId: string, categoryType: string, tableName: string} | null>(null)
// Загрузка категорий PartsIndex
const { data: partsIndexData, loading: partsIndexLoading, error: partsIndexError } = useQuery(
GET_PARTSINDEX_CATEGORIES,
{
variables: { lang: 'ru' },
errorPolicy: 'all'
}
)
// Загрузка категорий PartsAPI
const { data: partsAPIData, loading: partsAPILoading, error: partsAPIError } = useQuery(
GET_PARTSAPI_CATEGORIES,
{
variables: { carId: 9877, carType: 'PC' },
errorPolicy: 'all'
}
)
const partsIndexCategories = partsIndexData?.partsIndexCategoriesWithGroups || []
const partsAPICategories = partsAPIData?.partsAPICategories || []
const handleCategorySelect = (category: Category | PartsAPICategory, group?: any) => {
setSelectedCategory(category)
setSelectedGroup(group || null)
}
const handleBackToCategories = () => {
setSelectedCategory(null)
setSelectedGroup(null)
setViewingTable(null)
}
const handleViewTable = (categoryId: string, categoryType: string, tableName: string) => {
setViewingTable({ categoryId, categoryType, tableName })
setActiveTab('saved')
}
const filteredPartsIndexCategories = partsIndexCategories.filter(category =>
category.name.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredPartsAPICategories = partsAPICategories.filter(category =>
category.name.toLowerCase().includes(searchQuery.toLowerCase())
)
// Если выбрана категория, показываем её товары
if (selectedCategory) {
return (
<div className="container mx-auto py-6 px-4">
<div className="space-y-6">
{/* Заголовок с кнопкой возврата */}
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={handleBackToCategories}
className="flex items-center gap-2"
>
<ChevronRight className="h-4 w-4 rotate-180" />
Назад к категориям
</Button>
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold">Кража - {selectedCategory.name}</h1>
{selectedGroup && (
<>
<ChevronRight className="h-4 w-4 text-gray-400" />
<span className="text-lg text-gray-600">{selectedGroup.name}</span>
</>
)}
</div>
</div>
{/* Товары категории */}
<KrajaCategoryItems
category={selectedCategory}
group={selectedGroup}
categoryType={activeTab === 'partsindex' ? 'partsindex' : 'partsapi'}
/>
</div>
</div>
)
}
// Если просматриваем сохраненную таблицу
if (viewingTable && activeTab === 'saved') {
return (
<div className="container mx-auto py-6 px-4">
<div className="space-y-6">
{/* Заголовок с кнопкой возврата */}
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={handleBackToCategories}
className="flex items-center gap-2"
>
<ChevronRight className="h-4 w-4 rotate-180" />
Назад к таблицам
</Button>
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold">Сохраненные данные - {viewingTable.tableName}</h1>
<Badge variant="secondary">
{viewingTable.categoryType.toUpperCase()}
</Badge>
</div>
</div>
{/* Содержимое сохраненной таблицы */}
<KrajaCategoryItems
category={{ id: viewingTable.categoryId, name: viewingTable.tableName }}
categoryType={viewingTable.categoryType.toLowerCase() as 'partsindex' | 'partsapi'}
isViewingSavedData={true}
/>
</div>
</div>
)
}
return (
<div className="container mx-auto py-6 px-4">
<div className="space-y-6">
{/* Заголовок */}
<div className="flex items-center gap-3">
<Shield className="h-8 w-8 text-blue-600" />
<div>
<h1 className="text-3xl font-bold">Кража</h1>
<p className="text-gray-600">
Просмотр категорий и товаров из PartsIndex и PartsAPI
</p>
</div>
</div>
{/* Поиск */}
{activeTab !== 'saved' && (
<Card>
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Поиск по категориям..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
)}
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Package 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">
<Package className="h-5 w-5 text-green-600" />
<div>
<div className="text-sm text-gray-600">Категорий PartsAPI</div>
<div className="text-2xl font-bold">{partsAPICategories.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Eye className="h-5 w-5 text-purple-600" />
<div>
<div className="text-sm text-gray-600">Всего категорий</div>
<div className="text-2xl font-bold">{partsIndexCategories.length + partsAPICategories.length}</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Табы с категориями */}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'partsindex' | 'partsapi' | 'saved')}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="partsindex" className="flex items-center gap-2">
<Package className="h-4 w-4" />
PartsIndex
<Badge variant="secondary">{partsIndexCategories.length}</Badge>
</TabsTrigger>
<TabsTrigger value="partsapi" className="flex items-center gap-2">
<Package className="h-4 w-4" />
PartsAPI
<Badge variant="secondary">{partsAPICategories.length}</Badge>
</TabsTrigger>
<TabsTrigger value="saved" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Сохраненные
</TabsTrigger>
</TabsList>
<TabsContent value="partsindex" className="space-y-4">
{partsIndexLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<span className="ml-2 text-gray-600">Загрузка категорий PartsIndex...</span>
</div>
) : partsIndexError ? (
<Card>
<CardContent className="p-6">
<div className="text-center text-red-600">
Ошибка загрузки категорий PartsIndex: {partsIndexError.message}
</div>
</CardContent>
</Card>
) : (
<KrajaCategories
categories={filteredPartsIndexCategories}
onCategorySelect={handleCategorySelect}
type="partsindex"
/>
)}
</TabsContent>
<TabsContent value="partsapi" className="space-y-4">
{partsAPILoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<span className="ml-2 text-gray-600">Загрузка категорий PartsAPI...</span>
</div>
) : partsAPIError ? (
<Card>
<CardContent className="p-6">
<div className="text-center text-red-600">
Ошибка загрузки категорий PartsAPI: {partsAPIError.message}
</div>
</CardContent>
</Card>
) : (
<KrajaCategories
categories={filteredPartsAPICategories}
onCategorySelect={handleCategorySelect}
type="partsapi"
/>
)}
</TabsContent>
<TabsContent value="saved" className="space-y-4">
<KrajaSavedTables onViewTable={handleViewTable} />
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@ -0,0 +1,381 @@
'use client'
import { useState } from 'react'
import { useMutation } from '@apollo/client'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Package,
ChevronRight,
FolderOpen,
Layers,
Image as ImageIcon,
Download,
Loader2
} from 'lucide-react'
import { FETCH_CATEGORY_PRODUCTS } from '@/lib/graphql/queries'
import toast from 'react-hot-toast'
interface PartsIndexCategory {
id: string
name: string
image?: string
groups?: Array<{
id: string
name: string
image?: string
subgroups?: Array<{
id: string
name: string
image?: string
entityNames?: Array<{
id: string
name: string
}>
}>
entityNames?: Array<{
id: string
name: string
}>
}>
}
interface PartsAPICategory {
id: string
name: string
level: number
parentId?: string
children?: PartsAPICategory[]
}
interface KrajaCategoriesProps {
categories: PartsIndexCategory[] | PartsAPICategory[]
onCategorySelect: (category: PartsIndexCategory | PartsAPICategory, group?: any) => void
type: 'partsindex' | 'partsapi'
}
export const KrajaCategories = ({ categories, onCategorySelect, type }: KrajaCategoriesProps) => {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [fetchingCategories, setFetchingCategories] = useState<Set<string>>(new Set())
const [fetchCategoryProducts] = useMutation(FETCH_CATEGORY_PRODUCTS, {
onCompleted: (data) => {
if (data.fetchCategoryProducts.success) {
toast.success(`${data.fetchCategoryProducts.message}`)
} else {
toast.error(`${data.fetchCategoryProducts.message}`)
}
},
onError: (error) => {
toast.error(`${error.message}`)
}
})
const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(categoryId)) {
newSet.delete(categoryId)
} else {
newSet.add(categoryId)
}
return newSet
})
}
const handleCategoryClick = (category: PartsIndexCategory | PartsAPICategory, group?: any) => {
onCategorySelect(category, group)
}
const handleFetchProducts = async (
category: PartsIndexCategory | PartsAPICategory,
group?: any,
fetchAll: boolean = false
) => {
const fetchKey = group ? `${category.id}_${group.id}` : category.id
setFetchingCategories(prev => new Set(prev).add(fetchKey))
try {
await fetchCategoryProducts({
variables: {
input: {
categoryId: category.id,
categoryName: category.name,
categoryType: type.toUpperCase(),
groupId: group?.id,
groupName: group?.name,
fetchAll,
limit: fetchAll ? 1000 : 100
}
}
})
} catch (error) {
console.error('Fetch error:', error)
} finally {
setFetchingCategories(prev => {
const newSet = new Set(prev)
newSet.delete(fetchKey)
return newSet
})
}
}
if (!categories || categories.length === 0) {
return (
<Card>
<CardContent className="p-8">
<div className="text-center text-gray-500">
<Package className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>Категории не найдены</p>
</div>
</CardContent>
</Card>
)
}
if (type === 'partsindex') {
const partsIndexCategories = categories as PartsIndexCategory[]
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{partsIndexCategories.map((category) => (
<Card key={category.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="space-y-3">
{/* Заголовок категории */}
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center overflow-hidden">
{category.image ? (
<img
src={category.image}
alt={category.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-6 w-6 text-blue-600" />
)}
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{category.name}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{category.groups?.length || 0} групп
</Badge>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleCategory(category.id)}
className="text-gray-400 hover:text-gray-600"
>
<ChevronRight
className={`h-4 w-4 transition-transform ${
expandedCategories.has(category.id) ? 'rotate-90' : ''
}`}
/>
</Button>
</div>
{/* Группы категории */}
{expandedCategories.has(category.id) && category.groups && (
<div className="space-y-2 mt-3 border-t pt-3">
{category.groups.map((group) => (
<div key={group.id} className="space-y-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleCategoryClick(category, group)}
className="flex-1 justify-start text-left hover:bg-blue-50"
>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
{group.image ? (
<img
src={group.image}
alt={group.name}
className="w-full h-full object-cover"
/>
) : (
<FolderOpen className="h-3 w-3 text-gray-500" />
)}
</div>
<span className="text-sm text-gray-700">{group.name}</span>
{group.entityNames && (
<Badge variant="outline" className="text-xs ml-auto">
{group.entityNames.length} товаров
</Badge>
)}
</div>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleFetchProducts(category, group, true)}
disabled={fetchingCategories.has(`${category.id}_${group.id}`)}
className="px-2"
title="Сохранить все товары группы"
>
{fetchingCategories.has(`${category.id}_${group.id}`) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
</div>
{/* Подгруппы */}
{group.subgroups && group.subgroups.length > 0 && (
<div className="ml-6 space-y-1">
{group.subgroups.slice(0, 3).map((subgroup) => (
<Button
key={subgroup.id}
variant="ghost"
size="sm"
onClick={() => handleCategoryClick(category, subgroup)}
className="w-full justify-start text-left text-xs hover:bg-blue-50"
>
<div className="flex items-center gap-2">
<Layers className="h-3 w-3 text-gray-400" />
<span className="text-gray-600">{subgroup.name}</span>
{subgroup.entityNames && (
<Badge variant="outline" className="text-xs ml-auto">
{subgroup.entityNames.length}
</Badge>
)}
</div>
</Button>
))}
{group.subgroups.length > 3 && (
<div className="text-xs text-gray-500 ml-6">
и ещё {group.subgroups.length - 3} подгрупп...
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Кнопки действий */}
<div className="space-y-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={() => handleCategoryClick(category)}
className="w-full"
>
<Package className="h-4 w-4 mr-2" />
Просмотреть товары
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleFetchProducts(category, null, true)}
disabled={fetchingCategories.has(category.id)}
className="w-full"
>
{fetchingCategories.has(category.id) ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Сохранить все товары
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
// PartsAPI categories (tree structure)
const partsAPICategories = categories as PartsAPICategory[]
const renderPartsAPICategory = (category: PartsAPICategory, level: number = 0) => (
<div key={category.id} className={`${level > 0 ? 'ml-4' : ''}`}>
<Card className="mb-2 hover:shadow-md transition-shadow">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-green-50 rounded flex items-center justify-center">
<Package className="h-4 w-4 text-green-600" />
</div>
<div>
<h4 className="font-medium text-gray-900">{category.name}</h4>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
Уровень {category.level}
</Badge>
{category.children && category.children.length > 0 && (
<Badge variant="secondary" className="text-xs">
{category.children.length} подкатегорий
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleCategoryClick(category)}
>
Просмотреть
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleFetchProducts(category, null, true)}
disabled={fetchingCategories.has(category.id)}
title="Сохранить все товары категории"
>
{fetchingCategories.has(category.id) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
{category.children && category.children.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => toggleCategory(category.id)}
>
<ChevronRight
className={`h-4 w-4 transition-transform ${
expandedCategories.has(category.id) ? 'rotate-90' : ''
}`}
/>
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Подкатегории */}
{expandedCategories.has(category.id) && category.children && (
<div className="ml-4 mt-2">
{category.children.map((child) => renderPartsAPICategory(child, level + 1))}
</div>
)}
</div>
)
return (
<div className="space-y-2">
{partsAPICategories.map((category) => renderPartsAPICategory(category))}
</div>
)
}

View File

@ -0,0 +1,538 @@
'use client'
import { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Package,
Search,
Loader2,
AlertCircle,
Eye,
Filter,
Grid,
List
} from 'lucide-react'
import { GET_PARTSINDEX_CATALOG_ENTITIES, GET_PARTSAPI_ARTICLES, GET_CATEGORY_PRODUCTS } from '@/lib/graphql/queries'
interface PartsIndexCategory {
id: string
name: string
image?: string
groups?: Array<{
id: string
name: string
image?: string
subgroups?: Array<{
id: string
name: string
image?: string
entityNames?: Array<{
id: string
name: string
}>
}>
entityNames?: Array<{
id: string
name: string
}>
}>
}
interface PartsAPICategory {
id: string
name: string
level: number
parentId?: string
children?: PartsAPICategory[]
}
interface KrajaCategoryItemsProps {
category: PartsIndexCategory | PartsAPICategory
group?: any
categoryType: 'partsindex' | 'partsapi'
isViewingSavedData?: boolean
}
interface PartsIndexEntity {
id: string
name: string
image?: string
brand?: string
description?: string
price?: number
}
interface PartsAPIArticle {
supBrand: string
supId: number
productGroup: string
ptId: number
artSupBrand: string
artArticleNr: string
artId: string
}
export const KrajaCategoryItems = ({ category, group, categoryType, isViewingSavedData = false }: KrajaCategoryItemsProps) => {
const [searchQuery, setSearchQuery] = useState('')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = isViewingSavedData ? 100 : 20
// Для PartsIndex
const {
data: partsIndexData,
loading: partsIndexLoading,
error: partsIndexError,
refetch: refetchPartsIndex
} = useQuery(GET_PARTSINDEX_CATALOG_ENTITIES, {
variables: {
catalogId: categoryType === 'partsindex' ? category.id : undefined,
groupId: group?.id || undefined,
lang: 'ru',
limit: itemsPerPage,
page: currentPage,
q: searchQuery || undefined
},
skip: categoryType !== 'partsindex' || !category.id,
errorPolicy: 'all'
})
// Для PartsAPI - используем strId (нужно преобразовать id в число)
const {
data: partsAPIData,
loading: partsAPILoading,
error: partsAPIError,
refetch: refetchPartsAPI
} = useQuery(GET_PARTSAPI_ARTICLES, {
variables: {
strId: categoryType === 'partsapi' ? parseInt(category.id) : undefined,
carId: 9877,
carType: 'PC'
},
skip: categoryType !== 'partsapi' || !category.id || isViewingSavedData,
errorPolicy: 'all'
})
// Для просмотра сохраненных данных
const {
data: savedData,
loading: savedLoading,
error: savedError,
refetch: refetchSaved
} = useQuery(GET_CATEGORY_PRODUCTS, {
variables: {
categoryId: category.id,
categoryType: categoryType.toUpperCase(),
search: searchQuery || undefined,
limit: itemsPerPage,
offset: (currentPage - 1) * itemsPerPage
},
skip: !isViewingSavedData,
errorPolicy: 'all'
})
// Обновляем поиск с задержкой
useEffect(() => {
const timeoutId = setTimeout(() => {
if (isViewingSavedData) {
refetchSaved()
} else if (categoryType === 'partsindex') {
refetchPartsIndex()
} else {
refetchPartsAPI()
}
}, 500)
return () => clearTimeout(timeoutId)
}, [searchQuery, categoryType, isViewingSavedData, refetchPartsIndex, refetchPartsAPI, refetchSaved])
const isLoading = isViewingSavedData
? savedLoading
: (categoryType === 'partsindex' ? partsIndexLoading : partsAPILoading)
const error = isViewingSavedData
? savedError
: (categoryType === 'partsindex' ? partsIndexError : partsAPIError)
const items = isViewingSavedData
? savedData?.getCategoryProducts?.products || []
: (categoryType === 'partsindex'
? partsIndexData?.partsIndexCatalogEntities?.list || []
: partsAPIData?.partsAPIArticles || [])
const renderPartsIndexItem = (item: PartsIndexEntity) => (
<Card key={item.id} className={`hover:shadow-md transition-shadow ${viewMode === 'list' ? 'mb-2' : ''}`}>
<CardContent className={`${viewMode === 'grid' ? 'p-4' : 'p-3'}`}>
{viewMode === 'grid' ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{item.image ? (
<img
src={item.image}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-6 w-6 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900 line-clamp-2">{item.name}</h4>
{item.brand && (
<Badge variant="outline" className="text-xs mt-1">
{item.brand}
</Badge>
)}
</div>
</div>
{item.description && (
<p className="text-sm text-gray-600 line-clamp-2">{item.description}</p>
)}
{item.price && (
<div className="text-lg font-semibold text-blue-600">
{item.price.toLocaleString('ru-RU')}
</div>
)}
<Button variant="outline" size="sm" className="w-full">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
) : (
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
{item.image ? (
<img
src={item.image}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-5 w-5 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{item.name}</h4>
<div className="flex items-center gap-2 mt-1">
{item.brand && (
<Badge variant="outline" className="text-xs">
{item.brand}
</Badge>
)}
{item.price && (
<span className="text-sm font-semibold text-blue-600">
{item.price.toLocaleString('ru-RU')}
</span>
)}
</div>
</div>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
)}
</CardContent>
</Card>
)
const renderSavedItem = (item: any) => (
<Card key={item.id} className={`hover:shadow-md transition-shadow ${viewMode === 'list' ? 'mb-2' : ''}`}>
<CardContent className={`${viewMode === 'grid' ? 'p-4' : 'p-3'}`}>
{viewMode === 'grid' ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{item.image_url ? (
<img
src={item.image_url}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-6 w-6 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900 line-clamp-2">{item.name}</h4>
{item.brand && (
<Badge variant="outline" className="text-xs mt-1">
{item.brand}
</Badge>
)}
</div>
</div>
{item.description && (
<p className="text-sm text-gray-600 line-clamp-2">{item.description}</p>
)}
{item.price && (
<div className="text-lg font-semibold text-blue-600">
{parseFloat(item.price).toLocaleString('ru-RU')}
</div>
)}
<div className="text-xs text-gray-500">
Сохранено: {new Date(item.created_at).toLocaleDateString('ru-RU')}
</div>
<Button variant="outline" size="sm" className="w-full">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
) : (
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
{item.image_url ? (
<img
src={item.image_url}
alt={item.name}
className="w-full h-full object-cover"
/>
) : (
<Package className="h-5 w-5 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{item.name}</h4>
<div className="flex items-center gap-2 mt-1">
{item.brand && (
<Badge variant="outline" className="text-xs">
{item.brand}
</Badge>
)}
{item.price && (
<span className="text-sm font-semibold text-blue-600">
{parseFloat(item.price).toLocaleString('ru-RU')}
</span>
)}
<span className="text-xs text-gray-500">
{new Date(item.created_at).toLocaleDateString('ru-RU')}
</span>
</div>
</div>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
)}
</CardContent>
</Card>
)
const renderPartsAPIItem = (item: PartsAPIArticle, index: number) => (
<Card key={`${item.artId}-${index}`} className={`hover:shadow-md transition-shadow ${viewMode === 'list' ? 'mb-2' : ''}`}>
<CardContent className={`${viewMode === 'grid' ? 'p-4' : 'p-3'}`}>
{viewMode === 'grid' ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<Package className="h-6 w-6 text-green-600" />
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{item.artArticleNr}</h4>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{item.artSupBrand}
</Badge>
</div>
</div>
</div>
<div className="space-y-1">
<p className="text-sm text-gray-600">Группа: {item.productGroup}</p>
<p className="text-xs text-gray-500">Поставщик: {item.supBrand}</p>
<p className="text-xs text-gray-500">ID: {item.artId}</p>
</div>
<Button variant="outline" size="sm" className="w-full">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
) : (
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-green-100 rounded flex items-center justify-center">
<Package className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{item.artArticleNr}</h4>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{item.artSupBrand}
</Badge>
<span className="text-xs text-gray-500">{item.productGroup}</span>
</div>
</div>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
)}
</CardContent>
</Card>
)
return (
<div className="space-y-6">
{/* Панель управления */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
{/* Поиск */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Поиск товаров..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Элементы управления */}
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-sm">
{items.length} товаров
</Badge>
<div className="flex items-center border rounded-md">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="rounded-r-none"
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="rounded-l-none"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Содержимое */}
{isLoading ? (
<Card>
<CardContent className="p-12">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 mr-3" />
<span className="text-gray-600">Загрузка товаров...</span>
</div>
</CardContent>
</Card>
) : error ? (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center text-red-600">
<AlertCircle className="h-6 w-6 mr-2" />
<span>Ошибка загрузки: {error.message}</span>
</div>
</CardContent>
</Card>
) : items.length === 0 ? (
<Card>
<CardContent className="p-12">
<div className="text-center text-gray-500">
<Package className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-lg mb-2">Товары не найдены</p>
<p className="text-sm">Попробуйте изменить критерии поиска</p>
</div>
</CardContent>
</Card>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'
: 'space-y-2'
}>
{isViewingSavedData
? items.map((item: any) => renderSavedItem(item))
: (categoryType === 'partsindex'
? items.map((item: PartsIndexEntity) => renderPartsIndexItem(item))
: items.map((item: PartsAPIArticle, index: number) => renderPartsAPIItem(item, index))
)
}
</div>
)}
{/* Пагинация и статистика */}
{isViewingSavedData && savedData?.getCategoryProducts && (
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Показано {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, savedData.getCategoryProducts.total)} из {savedData.getCategoryProducts.total.toLocaleString()} сохраненных товаров
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
Предыдущая
</Button>
<span className="text-sm text-gray-600 px-2">
Страница {currentPage}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={items.length < itemsPerPage}
>
Следующая
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Пагинация для обычного просмотра */}
{!isViewingSavedData && items.length >= itemsPerPage && (
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
Предыдущая
</Button>
<span className="text-sm text-gray-600 px-4">
Страница {currentPage}
</span>
<Button
variant="outline"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={items.length < itemsPerPage}
>
Следующая
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,172 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Database,
Trash2,
Eye,
RefreshCw,
AlertCircle
} from 'lucide-react'
import { GET_CATEGORY_TABLES, DELETE_CATEGORY_TABLE } from '@/lib/graphql/queries'
import toast from 'react-hot-toast'
interface CategoryTable {
tableName: string
categoryId: string
categoryType: string
recordCount: number
}
interface KrajaSavedTablesProps {
onViewTable: (categoryId: string, categoryType: string, tableName: string) => void
}
export const KrajaSavedTables = ({ onViewTable }: KrajaSavedTablesProps) => {
const { data, loading, error, refetch } = useQuery(GET_CATEGORY_TABLES, {
errorPolicy: 'all',
fetchPolicy: 'cache-and-network'
})
const [deleteCategoryTable] = useMutation(DELETE_CATEGORY_TABLE, {
onCompleted: () => {
toast.success('✅ Таблица удалена')
refetch()
},
onError: (error) => {
toast.error(`${error.message}`)
}
})
const tables: CategoryTable[] = data?.getCategoryTables || []
const handleDeleteTable = async (categoryId: string, categoryType: string) => {
if (!confirm('Вы уверены, что хотите удалить эту таблицу? Все данные будут потеряны.')) {
return
}
try {
await deleteCategoryTable({
variables: {
categoryId,
categoryType: categoryType.toUpperCase()
}
})
} catch (error) {
console.error('Delete error:', error)
}
}
const getCategoryTypeColor = (type: string) => {
return type.toLowerCase() === 'partsindex' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
}
const getCategoryTypeLabel = (type: string) => {
return type.toLowerCase() === 'partsindex' ? 'PartsIndex' : 'PartsAPI'
}
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center">
<RefreshCw className="h-6 w-6 animate-spin text-gray-400 mr-2" />
<span className="text-gray-600">Загрузка сохраненных таблиц...</span>
</div>
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center text-red-600">
<AlertCircle className="h-6 w-6 mr-2" />
<span>Ошибка загрузки: {error.message}</span>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-blue-600" />
Сохраненные таблицы
</CardTitle>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Обновить
</Button>
</div>
</CardHeader>
<CardContent>
{tables.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Database className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-lg mb-2">Нет сохраненных таблиц</p>
<p className="text-sm">Используйте кнопки "Сохранить" в категориях для создания таблиц</p>
</div>
) : (
<div className="space-y-3">
{tables.map((table) => (
<div
key={table.tableName}
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-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<Database className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900">{table.tableName}</h4>
<Badge className={getCategoryTypeColor(table.categoryType)}>
{getCategoryTypeLabel(table.categoryType)}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>ID: {table.categoryId}</span>
<Badge variant="secondary" className="text-xs">
{table.recordCount.toLocaleString()} записей
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onViewTable(table.categoryId, table.categoryType, table.tableName)}
>
<Eye className="h-4 w-4 mr-2" />
Просмотреть
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteTable(table.categoryId, table.categoryType)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@ -15,7 +15,8 @@ import {
Receipt,
Palette,
Star,
Image
Image,
Shield
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/AuthProvider'
@ -35,6 +36,11 @@ const navigationItems = [
href: '/dashboard/catalog',
icon: Package,
},
{
title: 'Кража',
href: '/dashboard/kraja',
icon: Shield,
},
{
title: 'Навигация сайта',
href: '/dashboard/navigation',

View File

@ -1467,6 +1467,138 @@ export const GET_PARTSINDEX_CATEGORIES = gql`
}
`;
// PartsAPI категории
export const GET_PARTSAPI_CATEGORIES = gql`
query GetPartsAPICategories($carId: Int!, $carType: CarType) {
partsAPICategories(carId: $carId, carType: $carType) {
id
name
level
parentId
children {
id
name
level
parentId
children {
id
name
level
parentId
}
}
}
}
`;
// PartsIndex товары каталога
export const GET_PARTSINDEX_CATALOG_ENTITIES = gql`
query GetPartsIndexCatalogEntities(
$catalogId: String!
$groupId: String!
$lang: String
$limit: Int
$page: Int
$q: String
$engineId: String
$generationId: String
$params: String
) {
partsIndexCatalogEntities(
catalogId: $catalogId
groupId: $groupId
lang: $lang
limit: $limit
page: $page
q: $q
engineId: $engineId
generationId: $generationId
params: $params
) {
list {
id
name
image
brand
description
price
}
totalCount
page
limit
}
}
`;
// PartsAPI артикулы
export const GET_PARTSAPI_ARTICLES = gql`
query GetPartsAPIArticles($strId: Int!, $carId: Int!, $carType: CarType) {
partsAPIArticles(strId: $strId, carId: $carId, carType: $carType) {
supBrand
supId
productGroup
ptId
artSupBrand
artArticleNr
artId
}
}
`;
// Кража - мутации для работы с базой данных запчастей
export const FETCH_CATEGORY_PRODUCTS = gql`
mutation FetchCategoryProducts($input: FetchCategoryProductsInput!) {
fetchCategoryProducts(input: $input) {
success
message
insertedCount
tableName
}
}
`;
export const GET_CATEGORY_TABLES = gql`
query GetCategoryTables {
getCategoryTables {
tableName
categoryId
categoryType
recordCount
}
}
`;
export const DELETE_CATEGORY_TABLE = gql`
mutation DeleteCategoryTable($categoryId: String!, $categoryType: CategoryType!) {
deleteCategoryTable(categoryId: $categoryId, categoryType: $categoryType)
}
`;
export const GET_CATEGORY_PRODUCTS = gql`
query GetCategoryProducts($categoryId: String!, $categoryType: CategoryType!, $search: String, $limit: Int, $offset: Int) {
getCategoryProducts(categoryId: $categoryId, categoryType: $categoryType, search: $search, limit: $limit, offset: $offset) {
products {
id
external_id
name
brand
article
description
image_url
price
category_id
category_name
category_type
group_id
group_name
created_at
updated_at
}
total
}
}
`;
// Hero Banners queries
export const GET_HERO_BANNERS = gql`
query GetHeroBanners {

View File

@ -10,6 +10,7 @@ import { autoEuroService } from '../autoeuro-service'
import { yooKassaService } from '../yookassa-service'
import { partsAPIService } from '../partsapi-service'
import { partsIndexService } from '../partsindex-service'
import { partsDb } from '../parts-db'
import { yandexDeliveryService, YandexPickupPoint, getAddressSuggestions } from '../yandex-delivery-service'
import { InvoiceService } from '../invoice-service'
import * as csvWriter from 'csv-writer'
@ -2297,10 +2298,18 @@ export const resolvers = {
// Поиск товаров и предложений
searchProductOffers: async (_: unknown, {
articleNumber,
brand
brand,
cartItems = []
}: {
articleNumber: string;
brand: string;
cartItems?: Array<{
productId?: string;
offerKey?: string;
article: string;
brand: string;
quantity: number;
}>;
}, context: Context) => {
try {
// Проверяем входные параметры
@ -2323,6 +2332,18 @@ export const resolvers = {
const cleanBrand = brand.trim()
console.log('🔍 GraphQL Resolver - поиск предложений для товара:', { articleNumber: cleanArticleNumber, brand: cleanBrand })
console.log('🛒 Получено товаров в корзине:', cartItems.length)
// Функция для проверки, находится ли товар в корзине
const isItemInCart = (productId?: string, offerKey?: string, article?: string, brand?: string): boolean => {
return cartItems.some(cartItem => {
// Проверяем по разным комбинациям идентификаторов
if (productId && cartItem.productId === productId) return true;
if (offerKey && cartItem.offerKey === offerKey) return true;
if (article && brand && cartItem.article === article && cartItem.brand === brand) return true;
return false;
});
};
// 1. Поиск в нашей базе данных
const internalProducts = await prisma.product.findMany({
@ -2376,7 +2397,8 @@ export const resolvers = {
warehouseName: offer.warehouse_name || null,
rejects: offer.rejects || 0,
supplier: 'AutoEuro',
canPurchase: true
canPurchase: true,
isInCart: isItemInCart(undefined, offer.offer_key, offer.code, offer.brand)
}))
console.log('🎯 GraphQL Resolver - создано внешних предложений:', externalOffers.length)
@ -2487,7 +2509,9 @@ export const resolvers = {
deliveryDays: 1,
available: (product.stock || 0) > 0,
rating: 4.8,
supplier: 'Protek'
supplier: 'Protek',
canPurchase: true,
isInCart: isItemInCart(product.id, undefined, cleanArticleNumber, cleanBrand)
}))
// 6. Определяем название товара и собираем данные
@ -2549,6 +2573,24 @@ export const resolvers = {
productName = `${cleanBrand} ${cleanArticleNumber}`
}
// Расчет детализированной информации о наличии
const stockCalculation = {
totalInternalStock: internalOffers.reduce((sum, offer) => sum + (offer.quantity || 0), 0),
totalExternalStock: externalOffers.reduce((sum, offer) => sum + (offer.quantity || 0), 0),
availableInternalOffers: internalOffers.filter(offer => offer.available && offer.quantity > 0).length,
availableExternalOffers: externalOffers.filter(offer => offer.quantity > 0).length,
hasInternalStock: internalOffers.some(offer => offer.available && offer.quantity > 0),
hasExternalStock: externalOffers.some(offer => offer.quantity > 0),
totalStock: 0,
hasAnyStock: false
}
stockCalculation.totalStock = stockCalculation.totalInternalStock + stockCalculation.totalExternalStock
stockCalculation.hasAnyStock = stockCalculation.hasInternalStock || stockCalculation.hasExternalStock
// Проверяем, находится ли основной товар в корзине
const isMainProductInCart = isItemInCart(undefined, undefined, cleanArticleNumber, cleanBrand);
const result = {
articleNumber: cleanArticleNumber,
brand: cleanBrand,
@ -2561,25 +2603,49 @@ export const resolvers = {
internalOffers,
externalOffers,
analogs,
hasInternalStock: internalOffers.some(offer => offer.available),
totalOffers: internalOffers.length + externalOffers.length
hasInternalStock: stockCalculation.hasInternalStock,
totalOffers: internalOffers.length + externalOffers.length,
stockCalculation,
isInCart: isMainProductInCart
}
// Детализированное логирование результатов поиска
console.log('✅ Результат поиска предложений:', {
articleNumber: cleanArticleNumber,
brand: cleanBrand,
internalOffers: result.internalOffers.length,
externalOffers: result.externalOffers.length,
analogs: result.analogs.length,
hasInternalStock: result.hasInternalStock
totalOffers: result.totalOffers,
stockStatus: {
hasAnyStock: stockCalculation.hasAnyStock,
totalStock: stockCalculation.totalStock,
internalStock: stockCalculation.totalInternalStock,
externalStock: stockCalculation.totalExternalStock,
availableInternalOffers: stockCalculation.availableInternalOffers,
availableExternalOffers: stockCalculation.availableExternalOffers
}
})
console.log('🔍 Детали результата:')
console.log('- Внутренние предложения:', result.internalOffers)
console.log('- Внешние предложения:', result.externalOffers.slice(0, 3))
console.log('- Аналоги:', result.analogs.length)
console.log('📊 Детализация по предложениям:')
console.log(`- Внутренние предложения: ${result.internalOffers.length} (доступно: ${stockCalculation.availableInternalOffers}, общий сток: ${stockCalculation.totalInternalStock})`)
console.log(`- Внешние предложения: ${result.externalOffers.length} (доступно: ${stockCalculation.availableExternalOffers}, общий сток: ${stockCalculation.totalExternalStock})`)
console.log(`- Аналоги: ${result.analogs.length}`)
console.log(`- Итого в наличии: ${stockCalculation.hasAnyStock ? 'ДА' : 'НЕТ'} (${stockCalculation.totalStock} шт.)`)
// Сохраняем в историю поиска
// Логирование каждого предложения с деталями
if (result.internalOffers.length > 0) {
console.log('🏪 Внутренние предложения:')
result.internalOffers.forEach((offer, index) => {
console.log(` ${index + 1}. ${offer.productId} - ${offer.quantity} шт. (доступно: ${offer.available ? 'ДА' : 'НЕТ'}) - ${offer.price}₽ - склад: ${offer.warehouse}`)
})
}
if (result.externalOffers.length > 0) {
console.log('🌐 Внешние предложения (первые 5):')
result.externalOffers.slice(0, 5).forEach((offer, index) => {
console.log(` ${index + 1}. ${offer.code} (${offer.brand}) - ${offer.quantity} шт. - ${offer.price}₽ - поставщик: ${offer.supplier}`)
})
}
// Сохраняем в историю поиска с расширенной информацией
await saveSearchHistory(
context,
`${cleanBrand} ${cleanArticleNumber}`,
@ -4145,6 +4211,27 @@ export const resolvers = {
console.error('Ошибка получения баннера героя:', error)
throw new Error('Не удалось получить баннер героя')
}
},
// Корзина
getCart: async (_: unknown, {}, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return null;
}
const cart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return cart;
} catch (error) {
console.error('❌ Error getting cart:', error);
return null;
}
}
},
@ -9669,6 +9756,419 @@ export const resolvers = {
}
throw new Error('Не удалось удалить баннер героя')
}
},
// Кража - мутации для работы с базой данных запчастей
fetchCategoryProducts: async (_: unknown, { input }: { input: any }, context: Context) => {
try {
if (!context.userId || context.userRole !== 'ADMIN') {
throw new Error('Недостаточно прав для выполнения операции')
}
const { categoryId, categoryName, categoryType, groupId, groupName, limit = 100, fetchAll = false } = input
console.log('🔍 Fetching products for category:', {
categoryId,
categoryName,
categoryType,
groupId,
groupName,
limit,
fetchAll
})
let products: any[] = []
if (categoryType === 'PARTSINDEX') {
if (!groupId) {
// If no groupId, try to fetch all groups for this category
console.log('🔍 No groupId provided, fetching all groups for category:', categoryId)
const catalogGroups = await partsIndexService.getCatalogGroups(categoryId, 'ru')
console.log('✅ Found groups for category:', catalogGroups.length)
if (catalogGroups.length === 0) {
return {
success: false,
message: 'No groups found for this PartsIndex category',
insertedCount: 0,
tableName: null
}
}
// Fetch products from all groups (limit per group to avoid too much data)
const allProducts: any[] = []
const maxProductsPerGroup = fetchAll
? Math.max(5000, Math.floor(50000 / catalogGroups.length)) // Гораздо более щедрый лимит при fetchAll
: Math.max(1, Math.floor(limit / catalogGroups.length))
for (const group of catalogGroups.slice(0, 10)) { // Limit to first 10 groups
try {
let groupProducts: any[] = []
if (fetchAll) {
// Используем новый метод для получения ВСЕХ товаров группы
groupProducts = await partsIndexService.getAllCatalogEntities(categoryId, group.id, {
lang: 'ru',
maxItems: maxProductsPerGroup
})
} else {
// Обычный метод с лимитом
const entitiesData = await partsIndexService.getCatalogEntities(categoryId, group.id, {
lang: 'ru',
limit: maxProductsPerGroup,
page: 1
})
groupProducts = entitiesData?.list || []
}
// Add group info to each product
const productsWithGroup = groupProducts.map(product => ({
...product,
groupId: group.id,
groupName: group.name
}))
allProducts.push(...productsWithGroup)
console.log(`✅ Fetched ${groupProducts.length} products from group: ${group.name}`)
} catch (error) {
console.error(`❌ Error fetching products from group ${group.id}:`, error)
}
}
products = allProducts
console.log('✅ Fetched total PartsIndex products:', products.length)
} else {
// Fetch from specific group
if (fetchAll) {
// Используем новый метод для получения ВСЕХ товаров группы
products = await partsIndexService.getAllCatalogEntities(categoryId, groupId, {
lang: 'ru',
maxItems: 50000 // Максимум товаров для одной группы
})
} else {
// Обычный метод с лимитом
const entitiesData = await partsIndexService.getCatalogEntities(categoryId, groupId, {
lang: 'ru',
limit,
page: 1
})
products = entitiesData?.list || []
}
console.log('✅ Fetched PartsIndex products from group:', products.length)
}
} else if (categoryType === 'PARTSAPI') {
const articlesData = await partsAPIService.getArticles(parseInt(categoryId), 9877, 'PC')
products = articlesData || []
console.log('✅ Fetched PartsAPI products:', products.length)
} else {
throw new Error('Invalid category type')
}
if (products.length === 0) {
return {
success: false,
message: 'No products found for this category',
insertedCount: 0,
tableName: null
}
}
console.log(`📊 About to insert ${products.length} products into database`)
console.log(`📋 Sample product data:`, products.slice(0, 3))
// Insert products into parts database
const insertedCount = await partsDb.insertProducts(
categoryId,
categoryName,
categoryType.toLowerCase() as 'partsindex' | 'partsapi',
products,
groupId,
groupName
)
console.log(`✅ Database insertion result: ${insertedCount} of ${products.length} products saved`)
const tableName = `category_${categoryType.toLowerCase()}_${categoryId.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()}`
return {
success: true,
message: `Successfully fetched and saved ${insertedCount} products`,
insertedCount,
tableName
}
} catch (error) {
console.error('❌ Error fetching category products:', error)
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
insertedCount: 0,
tableName: null
}
}
},
getCategoryTables: async (_: unknown, __: unknown, context: Context) => {
try {
if (!context.userId || context.userRole !== 'ADMIN') {
throw new Error('Недостаточно прав для выполнения операции')
}
const tables = await partsDb.getCategoryTables()
return tables
} catch (error) {
console.error('❌ Error getting category tables:', error)
return []
}
},
deleteCategoryTable: async (_: unknown, { categoryId, categoryType }: { categoryId: string, categoryType: string }, context: Context) => {
try {
if (!context.userId || context.userRole !== 'ADMIN') {
throw new Error('Недостаточно прав для выполнения операции')
}
await partsDb.deleteCategoryTable(categoryId, categoryType.toLowerCase() as 'partsindex' | 'partsapi')
return true
} catch (error) {
console.error('❌ Error deleting category table:', error)
throw new Error('Failed to delete category table')
}
},
getCategoryProducts: async (_: unknown, {
categoryId,
categoryType,
search,
limit = 50,
offset = 0
}: {
categoryId: string,
categoryType: string,
search?: string,
limit?: number,
offset?: number
}, context: Context) => {
try {
if (!context.userId || context.userRole !== 'ADMIN') {
throw new Error('Недостаточно прав для выполнения операции')
}
const result = await partsDb.getProducts(categoryId, categoryType.toLowerCase() as 'partsindex' | 'partsapi', {
search,
limit,
offset
})
return {
products: result.products,
total: result.total
}
} catch (error) {
console.error('❌ Error getting category products:', error)
return {
products: [],
total: 0
}
}
},
// Корзина
addToCart: async (_: unknown, { input }: { input: any }, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return {
success: false,
error: 'Клиент не идентифицирован'
};
}
console.log('🛒 Adding to cart for client:', clientId);
// Находим или создаем корзину
let cart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
if (!cart) {
cart = await prisma.cart.create({
data: { clientId },
include: { items: true }
});
}
// Проверяем, есть ли уже такой товар в корзине
const existingItem = cart.items.find(item =>
(item.productId && input.productId && item.productId === input.productId) ||
(item.offerKey && input.offerKey && item.offerKey === input.offerKey) ||
(item.article === input.article && item.brand === input.brand)
);
if (existingItem) {
// Увеличиваем количество
await prisma.cartItem.update({
where: { id: existingItem.id },
data: { quantity: existingItem.quantity + input.quantity }
});
} else {
// Добавляем новый товар
await prisma.cartItem.create({
data: {
cartId: cart.id,
productId: input.productId,
offerKey: input.offerKey,
name: input.name,
description: input.description,
brand: input.brand,
article: input.article,
price: input.price,
currency: input.currency,
quantity: input.quantity,
stock: input.stock,
deliveryTime: input.deliveryTime,
warehouse: input.warehouse,
supplier: input.supplier,
isExternal: input.isExternal,
image: input.image
}
});
}
// Получаем обновленную корзину
const updatedCart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return {
success: true,
message: 'Товар добавлен в корзину',
cart: updatedCart
};
} catch (error) {
console.error('❌ Error adding to cart:', error);
return {
success: false,
error: 'Ошибка добавления товара в корзину'
};
}
},
removeFromCart: async (_: unknown, { itemId }: { itemId: string }, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return {
success: false,
error: 'Клиент не идентифицирован'
};
}
await prisma.cartItem.delete({
where: { id: itemId }
});
const updatedCart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return {
success: true,
message: 'Товар удален из корзины',
cart: updatedCart
};
} catch (error) {
console.error('❌ Error removing from cart:', error);
return {
success: false,
error: 'Ошибка удаления товара из корзины'
};
}
},
updateCartItemQuantity: async (_: unknown, { itemId, quantity }: { itemId: string; quantity: number }, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return {
success: false,
error: 'Клиент не идентифицирован'
};
}
await prisma.cartItem.update({
where: { id: itemId },
data: { quantity: Math.max(1, quantity) }
});
const updatedCart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return {
success: true,
message: 'Количество товара обновлено',
cart: updatedCart
};
} catch (error) {
console.error('❌ Error updating cart item quantity:', error);
return {
success: false,
error: 'Ошибка обновления количества товара'
};
}
},
clearCart: async (_: unknown, {}, context: Context) => {
try {
const clientId = context.clientId;
if (!clientId) {
return {
success: false,
error: 'Клиент не идентифицирован'
};
}
await prisma.cartItem.deleteMany({
where: {
cart: {
clientId
}
}
});
const updatedCart = await prisma.cart.findUnique({
where: { clientId },
include: { items: true }
});
return {
success: true,
message: 'Корзина очищена',
cart: updatedCart
};
} catch (error) {
console.error('❌ Error clearing cart:', error);
return {
success: false,
error: 'Ошибка очистки корзины'
};
}
}
}
}

View File

@ -966,7 +966,8 @@ export const typeDefs = gql`
# Поиск товаров и предложений
searchProductOffers(
articleNumber: String!,
brand: String!
brand: String!,
cartItems: [CartItemInput!]
): ProductOffersResult!
getAnalogOffers(analogs: [AnalogOfferInput!]!): [AnalogProduct!]
getBrandsByCode(code: String!): BrandsByCodeResponse!
@ -1060,6 +1061,9 @@ export const typeDefs = gql`
# Новые поступления
newArrivals(limit: Int = 8): [Product!]!
# Корзина
getCart: Cart
}
type AuthPayload {
@ -1271,6 +1275,19 @@ export const typeDefs = gql`
createHeroBanner(input: HeroBannerInput!): HeroBanner!
updateHeroBanner(id: String!, input: HeroBannerUpdateInput!): HeroBanner!
deleteHeroBanner(id: String!): Boolean!
# Кража - работа с базой данных запчастей
fetchCategoryProducts(input: FetchCategoryProductsInput!): FetchCategoryProductsResult!
getCategoryTables: [CategoryTable!]!
deleteCategoryTable(categoryId: String!, categoryType: CategoryType!): Boolean!
getCategoryProducts(categoryId: String!, categoryType: CategoryType!, search: String, limit: Int, offset: Int): CategoryProductsResult!
# Корзина
addToCart(input: AddToCartInput!): AddToCartResult!
removeFromCart(itemId: ID!): AddToCartResult!
updateCartItemQuantity(itemId: ID!, quantity: Int!): AddToCartResult!
clearCart: AddToCartResult!
getCart: Cart
}
input LoginInput {
@ -1707,6 +1724,69 @@ export const typeDefs = gql`
category: String
}
# Типы для корзины
input CartItemInput {
productId: String
offerKey: String
article: String!
brand: String!
quantity: Int!
}
input AddToCartInput {
productId: String
offerKey: String
name: String!
description: String!
brand: String!
article: String!
price: Float!
currency: String!
quantity: Int!
stock: Int
deliveryTime: String
warehouse: String
supplier: String
isExternal: Boolean!
image: String
}
type CartItem {
id: ID!
productId: String
offerKey: String
name: String!
description: String!
brand: String!
article: String!
price: Float!
currency: String!
quantity: Int!
stock: Int
deliveryTime: String
warehouse: String
supplier: String
isExternal: Boolean!
image: String
createdAt: String!
updatedAt: String!
}
type Cart {
id: ID!
clientId: String!
items: [CartItem!]!
createdAt: String!
updatedAt: String!
}
type AddToCartResult {
success: Boolean!
message: String
cart: Cart
error: String
}
# Типы для поиска товаров и предложений
type ProductOffersResult {
articleNumber: String!
@ -1722,6 +1802,19 @@ export const typeDefs = gql`
analogs: [AnalogInfo!]!
hasInternalStock: Boolean!
totalOffers: Int!
stockCalculation: StockCalculation!
isInCart: Boolean!
}
type StockCalculation {
totalInternalStock: Int!
totalExternalStock: Int!
availableInternalOffers: Int!
availableExternalOffers: Int!
hasInternalStock: Boolean!
hasExternalStock: Boolean!
totalStock: Int!
hasAnyStock: Boolean!
}
type PartsIndexImage {
@ -1748,6 +1841,7 @@ export const typeDefs = gql`
rating: Float
supplier: String!
canPurchase: Boolean!
isInCart: Boolean!
}
type ExternalOffer {
@ -1760,6 +1854,7 @@ export const typeDefs = gql`
deliveryTime: Int!
deliveryTimeMax: Int!
quantity: Int!
isInCart: Boolean!
warehouse: String!
warehouseName: String
rejects: Float
@ -2342,4 +2437,58 @@ export const typeDefs = gql`
isActive: Boolean
sortOrder: Int
}
# Кража - типы для работы с базой данных запчастей
enum CategoryType {
PARTSINDEX
PARTSAPI
}
input FetchCategoryProductsInput {
categoryId: String!
categoryName: String!
categoryType: CategoryType!
groupId: String
groupName: String
limit: Int
fetchAll: Boolean
}
type FetchCategoryProductsResult {
success: Boolean!
message: String!
insertedCount: Int
tableName: String
}
type CategoryTable {
tableName: String!
categoryId: String!
categoryType: String!
recordCount: Int!
}
type CategoryProductsResult {
products: [CategoryProduct!]!
total: Int!
}
type CategoryProduct {
id: Int!
external_id: String!
name: String!
brand: String
article: String
description: String
image_url: String
price: Float
category_id: String!
category_name: String!
category_type: String!
group_id: String
group_name: String
raw_data: JSON
created_at: DateTime!
updated_at: DateTime!
}
`

361
src/lib/parts-db.ts Normal file
View File

@ -0,0 +1,361 @@
import { Pool } from 'pg'
class PartsDatabase {
private pool: Pool
constructor() {
const connectionString = process.env.DATABASE_URL
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set')
}
this.pool = new Pool({
connectionString,
max: 10, // Reduce concurrent connections to avoid overwhelming the DB
idleTimeoutMillis: 60000, // 1 minute
connectionTimeoutMillis: 10000, // 10 seconds
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
})
console.log('🔌 Parts Database connection initialized (using main DATABASE_URL)')
}
// Create table for a specific category
async createCategoryTable(categoryId: string, categoryName: string, categoryType: 'partsindex' | 'partsapi'): Promise<void> {
const tableName = this.getCategoryTableName(categoryId, categoryType)
try {
const query = `
CREATE TABLE IF NOT EXISTS "${tableName}" (
id SERIAL PRIMARY KEY,
external_id VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(500) NOT NULL,
brand VARCHAR(255),
article VARCHAR(255),
description TEXT,
image_url VARCHAR(500),
price DECIMAL(10,2),
category_id VARCHAR(255) NOT NULL,
category_name VARCHAR(500) NOT NULL,
category_type VARCHAR(20) NOT NULL,
group_id VARCHAR(255),
group_name VARCHAR(500),
raw_data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS "idx_${tableName}_external_id" ON "${tableName}" (external_id);
CREATE INDEX IF NOT EXISTS "idx_${tableName}_category_id" ON "${tableName}" (category_id);
CREATE INDEX IF NOT EXISTS "idx_${tableName}_brand" ON "${tableName}" (brand);
CREATE INDEX IF NOT EXISTS "idx_${tableName}_article" ON "${tableName}" (article);
CREATE INDEX IF NOT EXISTS "idx_${tableName}_created_at" ON "${tableName}" (created_at);
-- Create trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_${tableName}_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
DROP TRIGGER IF EXISTS trigger_update_${tableName}_timestamp ON "${tableName}";
CREATE TRIGGER trigger_update_${tableName}_timestamp
BEFORE UPDATE ON "${tableName}"
FOR EACH ROW
EXECUTE FUNCTION update_${tableName}_timestamp();
`
await this.pool.query(query)
console.log(`✅ Created table ${tableName} for category: ${categoryName}`)
} catch (error) {
console.error(`❌ Error creating table ${tableName}:`, error)
throw error
}
}
// Insert or update products in category table
async insertProducts(
categoryId: string,
categoryName: string,
categoryType: 'partsindex' | 'partsapi',
products: any[],
groupId?: string,
groupName?: string
): Promise<number> {
const tableName = this.getCategoryTableName(categoryId, categoryType)
console.log(`🔄 Starting to insert ${products.length} products into ${tableName}`)
// First ensure table exists
await this.createCategoryTable(categoryId, categoryName, categoryType)
let insertedCount = 0
let errorCount = 0
// Process in smaller batches to reduce connection pressure
const batchSize = 50
const batches: any[][] = []
for (let i = 0; i < products.length; i += batchSize) {
batches.push(products.slice(i, i + batchSize))
}
console.log(`📦 Processing ${products.length} products in ${batches.length} batches of ${batchSize}`)
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
const batch = batches[batchIndex]
console.log(`🔄 Processing batch ${batchIndex + 1}/${batches.length} (${batch.length} products)`)
for (let i = 0; i < batch.length; i++) {
const globalIndex = batchIndex * batchSize + i
const product = batch[i]
// Retry logic with exponential backoff
let retryAttempts = 0
const maxRetries = 3
let success = false
while (retryAttempts < maxRetries && !success) {
try {
const values = this.prepareProductData(product, categoryId, categoryName, categoryType, groupId, groupName)
const query = `
INSERT INTO "${tableName}" (
external_id, name, brand, article, description, image_url, price,
category_id, category_name, category_type, group_id, group_name, raw_data
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT (external_id)
DO UPDATE SET
name = EXCLUDED.name,
brand = EXCLUDED.brand,
article = EXCLUDED.article,
description = EXCLUDED.description,
image_url = EXCLUDED.image_url,
price = EXCLUDED.price,
group_id = EXCLUDED.group_id,
group_name = EXCLUDED.group_name,
raw_data = EXCLUDED.raw_data,
updated_at = CURRENT_TIMESTAMP
`
await this.pool.query(query, values)
insertedCount++
success = true
// Log progress every 100 insertions
if (insertedCount % 100 === 0) {
console.log(`📊 Progress: ${insertedCount}/${products.length} products inserted into ${tableName}`)
}
} catch (error: any) {
retryAttempts++
// Check if it's a network/connection error that might be retryable
if (error.code === 'ENOTFOUND' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
if (retryAttempts < maxRetries) {
const delayMs = Math.pow(2, retryAttempts) * 1000 // Exponential backoff: 2s, 4s, 8s
console.log(`🔄 Retry ${retryAttempts}/${maxRetries} for product ${globalIndex + 1} after ${delayMs}ms delay`)
await new Promise(resolve => setTimeout(resolve, delayMs))
continue
}
}
// If max retries exceeded or non-retryable error
errorCount++
console.error(`❌ Error inserting product ${globalIndex + 1}/${products.length} into ${tableName} (after ${retryAttempts} retries):`, error)
console.error(`❌ Product data:`, product)
break
}
}
}
// Small delay between batches to allow DB to recover
if (batchIndex < batches.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100))
}
}
console.log(`✅ Insertion complete for ${tableName}:`)
console.log(` - Successfully inserted/updated: ${insertedCount} products`)
console.log(` - Errors: ${errorCount} products`)
console.log(` - Total processed: ${products.length} products`)
return insertedCount
}
// Get products from category table
async getProducts(
categoryId: string,
categoryType: 'partsindex' | 'partsapi',
options: {
limit?: number
offset?: number
search?: string
} = {}
): Promise<{ products: any[], total: number }> {
const tableName = this.getCategoryTableName(categoryId, categoryType)
const { limit = 50, offset = 0, search } = options
try {
let whereClause = ''
let searchParams: any[] = []
if (search) {
whereClause = 'WHERE (name ILIKE $1 OR brand ILIKE $1 OR article ILIKE $1 OR description ILIKE $1)'
searchParams = [`%${search}%`]
}
// Count total
const countQuery = `SELECT COUNT(*) FROM "${tableName}" ${whereClause}`
const countResult = await this.pool.query(countQuery, searchParams)
const total = parseInt(countResult.rows[0].count)
// Get products
const dataQuery = `
SELECT * FROM "${tableName}"
${whereClause}
ORDER BY created_at DESC
LIMIT $${searchParams.length + 1} OFFSET $${searchParams.length + 2}
`
const dataResult = await this.pool.query(dataQuery, [...searchParams, limit, offset])
return {
products: dataResult.rows,
total
}
} catch (error) {
console.error(`❌ Error getting products from ${tableName}:`, error)
return { products: [], total: 0 }
}
}
// Get all category tables
async getCategoryTables(): Promise<{ tableName: string, categoryId: string, categoryType: string, recordCount: number }[]> {
try {
const query = `
SELECT
tablename,
schemaname
FROM pg_tables
WHERE schemaname = 'public'
AND (tablename LIKE 'category_partsindex_%' OR tablename LIKE 'category_partsapi_%')
ORDER BY tablename
`
const result = await this.pool.query(query)
const tables: { tableName: string, categoryId: string, categoryType: string, recordCount: number }[] = []
for (const row of result.rows) {
const tableName = row.tablename
// Parse category info from table name
const [, categoryType, categoryId] = tableName.split('_')
// Get record count
const countQuery = `SELECT COUNT(*) FROM "${tableName}"`
const countResult = await this.pool.query(countQuery)
const recordCount = parseInt(countResult.rows[0].count)
tables.push({
tableName,
categoryId,
categoryType,
recordCount
})
}
return tables
} catch (error) {
console.error('❌ Error getting category tables:', error)
return []
}
}
// Delete category table
async deleteCategoryTable(categoryId: string, categoryType: 'partsindex' | 'partsapi'): Promise<void> {
const tableName = this.getCategoryTableName(categoryId, categoryType)
try {
await this.pool.query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`)
console.log(`✅ Deleted table ${tableName}`)
} catch (error) {
console.error(`❌ Error deleting table ${tableName}:`, error)
throw error
}
}
// Helper method to generate table name
private getCategoryTableName(categoryId: string, categoryType: 'partsindex' | 'partsapi'): string {
// Sanitize category ID for use in table name
const sanitizedId = categoryId.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()
return `category_${categoryType}_${sanitizedId}`
}
// Helper method to prepare product data
private prepareProductData(
product: any,
categoryId: string,
categoryName: string,
categoryType: 'partsindex' | 'partsapi',
groupId?: string,
groupName?: string
): any[] {
if (categoryType === 'partsindex') {
return [
product.id || product.external_id || `${Date.now()}_${Math.random()}`,
product.name || '',
product.brand || '',
product.article || '',
product.description || '',
product.image || '',
product.price ? parseFloat(product.price) : null,
categoryId,
categoryName,
categoryType,
groupId || null,
groupName || null,
JSON.stringify(product)
]
} else {
// PartsAPI
return [
product.artId || `${Date.now()}_${Math.random()}`,
product.artArticleNr || '',
product.artSupBrand || '',
product.artArticleNr || '',
product.productGroup || '',
'', // no image for PartsAPI
null, // no price for PartsAPI
categoryId,
categoryName,
categoryType,
groupId || null,
groupName || null,
JSON.stringify(product)
]
}
}
// Test database connection
async testConnection(): Promise<boolean> {
try {
await this.pool.query('SELECT 1')
console.log('✅ Parts database connection test successful')
return true
} catch (error) {
console.error('❌ Parts database connection test failed:', error)
return false
}
}
// Close connection pool
async close(): Promise<void> {
await this.pool.end()
console.log('🔌 Parts database connection closed')
}
}
// Export singleton instance
export const partsDb = new PartsDatabase()

View File

@ -329,6 +329,87 @@ class PartsIndexService {
}
}
// Новый метод: получить ВСЕ товары каталога (с пагинацией)
async getAllCatalogEntities(
catalogId: string,
groupId: string,
options: {
lang?: 'ru' | 'en';
q?: string;
engineId?: string;
generationId?: string;
params?: Record<string, any>;
maxItems?: number;
} = {}
): Promise<PartsIndexEntity[]> {
const {
lang = 'ru',
q,
engineId,
generationId,
params,
maxItems = 10000
} = options;
try {
console.log('🔍 PartsIndex запрос ВСЕХ товаров каталога:', {
catalogId,
groupId,
lang,
q,
maxItems
});
const allEntities: PartsIndexEntity[] = [];
let currentPage = 1;
const itemsPerPage = 100; // Увеличиваем размер страницы для эффективности
let hasMorePages = true;
while (hasMorePages && allEntities.length < maxItems) {
const response = await this.getCatalogEntities(catalogId, groupId, {
lang,
limit: itemsPerPage,
page: currentPage,
q,
engineId,
generationId,
params
});
if (!response || !response.list || response.list.length === 0) {
hasMorePages = false;
break;
}
allEntities.push(...response.list);
console.log(`📄 Страница ${currentPage}: получено ${response.list.length} товаров, всего: ${allEntities.length}`);
// Проверяем, есть ли следующая страница
hasMorePages = response.pagination && response.pagination.page.next !== null && response.list.length === itemsPerPage;
currentPage++;
// Защита от бесконечного цикла
if (currentPage > 100) {
console.warn('⚠️ Достигнут лимит страниц (100), прерываем загрузку');
break;
}
// Небольшая задержка между запросами, чтобы не перегружать API
if (hasMorePages) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
console.log(`✅ PartsIndex получено всего товаров: ${allEntities.length}`);
return allEntities;
} catch (error) {
console.error('❌ Ошибка получения всех товаров PartsIndex:', error);
return [];
}
}
// Новый метод: получить товары каталога
async getCatalogEntities(
catalogId: string,

View File

@ -5,6 +5,9 @@ CMS_PORT=3000
# Подключение к внешней PostgreSQL базе
DATABASE_URL=postgresql://username:password@your-db-host:5432/protekauto_cms
# База данных для сохранения данных запчастей
PARTSDB_URL=postgresql://username:password@your-db-host:5432/protekauto_parts
# ===== АВТОРИЗАЦИЯ =====
# Секретный ключ для NextAuth (генерируйте случайно)
NEXTAUTH_SECRET=your-super-secret-key-here-change-me