pravki
This commit is contained in:
159
package-lock.json
generated
159
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
332
src/app/dashboard/kraja/page.tsx
Normal file
332
src/app/dashboard/kraja/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
381
src/components/kraja/KrajaCategories.tsx
Normal file
381
src/components/kraja/KrajaCategories.tsx
Normal 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>
|
||||
)
|
||||
}
|
538
src/components/kraja/KrajaCategoryItems.tsx
Normal file
538
src/components/kraja/KrajaCategoryItems.tsx
Normal 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>
|
||||
)
|
||||
}
|
172
src/components/kraja/KrajaSavedTables.tsx
Normal file
172
src/components/kraja/KrajaSavedTables.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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',
|
||||
|
@ -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 {
|
||||
|
@ -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: 'Ошибка очистки корзины'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
361
src/lib/parts-db.ts
Normal 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()
|
@ -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,
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user