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/jsonwebtoken": "^9.0.9",
|
||||||
"@types/jspdf": "^1.3.3",
|
"@types/jspdf": "^1.3.3",
|
||||||
"@types/pdfkit": "^0.14.0",
|
"@types/pdfkit": "^0.14.0",
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
@ -55,6 +56,7 @@
|
|||||||
"lucide-react": "^0.513.0",
|
"lucide-react": "^0.513.0",
|
||||||
"next": "15.3.3",
|
"next": "15.3.3",
|
||||||
"pdfkit": "^0.17.1",
|
"pdfkit": "^0.17.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prisma": "^6.9.0",
|
"prisma": "^6.9.0",
|
||||||
"puppeteer": "^24.10.2",
|
"puppeteer": "^24.10.2",
|
||||||
@ -4834,6 +4836,17 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/qrcode": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||||
@ -10766,6 +10779,95 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -10837,6 +10939,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -11914,6 +12055,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||||
@ -13168,6 +13318,15 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/y18n": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
@ -44,6 +44,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/jspdf": "^1.3.3",
|
"@types/jspdf": "^1.3.3",
|
||||||
"@types/pdfkit": "^0.14.0",
|
"@types/pdfkit": "^0.14.0",
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
@ -65,6 +66,7 @@
|
|||||||
"lucide-react": "^0.513.0",
|
"lucide-react": "^0.513.0",
|
||||||
"next": "15.3.3",
|
"next": "15.3.3",
|
||||||
"pdfkit": "^0.17.1",
|
"pdfkit": "^0.17.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prisma": "^6.9.0",
|
"prisma": "^6.9.0",
|
||||||
"puppeteer": "^24.10.2",
|
"puppeteer": "^24.10.2",
|
||||||
|
@ -797,6 +797,43 @@ enum DiscountCodeType {
|
|||||||
PROMOCODE
|
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 {
|
enum DeliveryType {
|
||||||
COURIER
|
COURIER
|
||||||
PICKUP
|
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,
|
Receipt,
|
||||||
Palette,
|
Palette,
|
||||||
Star,
|
Star,
|
||||||
Image
|
Image,
|
||||||
|
Shield
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useAuth } from '@/components/providers/AuthProvider'
|
import { useAuth } from '@/components/providers/AuthProvider'
|
||||||
@ -35,6 +36,11 @@ const navigationItems = [
|
|||||||
href: '/dashboard/catalog',
|
href: '/dashboard/catalog',
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Кража',
|
||||||
|
href: '/dashboard/kraja',
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Навигация сайта',
|
title: 'Навигация сайта',
|
||||||
href: '/dashboard/navigation',
|
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
|
// Hero Banners queries
|
||||||
export const GET_HERO_BANNERS = gql`
|
export const GET_HERO_BANNERS = gql`
|
||||||
query GetHeroBanners {
|
query GetHeroBanners {
|
||||||
|
@ -10,6 +10,7 @@ import { autoEuroService } from '../autoeuro-service'
|
|||||||
import { yooKassaService } from '../yookassa-service'
|
import { yooKassaService } from '../yookassa-service'
|
||||||
import { partsAPIService } from '../partsapi-service'
|
import { partsAPIService } from '../partsapi-service'
|
||||||
import { partsIndexService } from '../partsindex-service'
|
import { partsIndexService } from '../partsindex-service'
|
||||||
|
import { partsDb } from '../parts-db'
|
||||||
import { yandexDeliveryService, YandexPickupPoint, getAddressSuggestions } from '../yandex-delivery-service'
|
import { yandexDeliveryService, YandexPickupPoint, getAddressSuggestions } from '../yandex-delivery-service'
|
||||||
import { InvoiceService } from '../invoice-service'
|
import { InvoiceService } from '../invoice-service'
|
||||||
import * as csvWriter from 'csv-writer'
|
import * as csvWriter from 'csv-writer'
|
||||||
@ -2297,10 +2298,18 @@ export const resolvers = {
|
|||||||
// Поиск товаров и предложений
|
// Поиск товаров и предложений
|
||||||
searchProductOffers: async (_: unknown, {
|
searchProductOffers: async (_: unknown, {
|
||||||
articleNumber,
|
articleNumber,
|
||||||
brand
|
brand,
|
||||||
|
cartItems = []
|
||||||
}: {
|
}: {
|
||||||
articleNumber: string;
|
articleNumber: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
|
cartItems?: Array<{
|
||||||
|
productId?: string;
|
||||||
|
offerKey?: string;
|
||||||
|
article: string;
|
||||||
|
brand: string;
|
||||||
|
quantity: number;
|
||||||
|
}>;
|
||||||
}, context: Context) => {
|
}, context: Context) => {
|
||||||
try {
|
try {
|
||||||
// Проверяем входные параметры
|
// Проверяем входные параметры
|
||||||
@ -2323,6 +2332,18 @@ export const resolvers = {
|
|||||||
const cleanBrand = brand.trim()
|
const cleanBrand = brand.trim()
|
||||||
|
|
||||||
console.log('🔍 GraphQL Resolver - поиск предложений для товара:', { articleNumber: cleanArticleNumber, brand: cleanBrand })
|
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. Поиск в нашей базе данных
|
// 1. Поиск в нашей базе данных
|
||||||
const internalProducts = await prisma.product.findMany({
|
const internalProducts = await prisma.product.findMany({
|
||||||
@ -2376,7 +2397,8 @@ export const resolvers = {
|
|||||||
warehouseName: offer.warehouse_name || null,
|
warehouseName: offer.warehouse_name || null,
|
||||||
rejects: offer.rejects || 0,
|
rejects: offer.rejects || 0,
|
||||||
supplier: 'AutoEuro',
|
supplier: 'AutoEuro',
|
||||||
canPurchase: true
|
canPurchase: true,
|
||||||
|
isInCart: isItemInCart(undefined, offer.offer_key, offer.code, offer.brand)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
console.log('🎯 GraphQL Resolver - создано внешних предложений:', externalOffers.length)
|
console.log('🎯 GraphQL Resolver - создано внешних предложений:', externalOffers.length)
|
||||||
@ -2487,7 +2509,9 @@ export const resolvers = {
|
|||||||
deliveryDays: 1,
|
deliveryDays: 1,
|
||||||
available: (product.stock || 0) > 0,
|
available: (product.stock || 0) > 0,
|
||||||
rating: 4.8,
|
rating: 4.8,
|
||||||
supplier: 'Protek'
|
supplier: 'Protek',
|
||||||
|
canPurchase: true,
|
||||||
|
isInCart: isItemInCart(product.id, undefined, cleanArticleNumber, cleanBrand)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 6. Определяем название товара и собираем данные
|
// 6. Определяем название товара и собираем данные
|
||||||
@ -2549,6 +2573,24 @@ export const resolvers = {
|
|||||||
productName = `${cleanBrand} ${cleanArticleNumber}`
|
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 = {
|
const result = {
|
||||||
articleNumber: cleanArticleNumber,
|
articleNumber: cleanArticleNumber,
|
||||||
brand: cleanBrand,
|
brand: cleanBrand,
|
||||||
@ -2561,25 +2603,49 @@ export const resolvers = {
|
|||||||
internalOffers,
|
internalOffers,
|
||||||
externalOffers,
|
externalOffers,
|
||||||
analogs,
|
analogs,
|
||||||
hasInternalStock: internalOffers.some(offer => offer.available),
|
hasInternalStock: stockCalculation.hasInternalStock,
|
||||||
totalOffers: internalOffers.length + externalOffers.length
|
totalOffers: internalOffers.length + externalOffers.length,
|
||||||
|
stockCalculation,
|
||||||
|
isInCart: isMainProductInCart
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Детализированное логирование результатов поиска
|
||||||
console.log('✅ Результат поиска предложений:', {
|
console.log('✅ Результат поиска предложений:', {
|
||||||
articleNumber: cleanArticleNumber,
|
articleNumber: cleanArticleNumber,
|
||||||
brand: cleanBrand,
|
brand: cleanBrand,
|
||||||
internalOffers: result.internalOffers.length,
|
totalOffers: result.totalOffers,
|
||||||
externalOffers: result.externalOffers.length,
|
stockStatus: {
|
||||||
analogs: result.analogs.length,
|
hasAnyStock: stockCalculation.hasAnyStock,
|
||||||
hasInternalStock: result.hasInternalStock
|
totalStock: stockCalculation.totalStock,
|
||||||
|
internalStock: stockCalculation.totalInternalStock,
|
||||||
|
externalStock: stockCalculation.totalExternalStock,
|
||||||
|
availableInternalOffers: stockCalculation.availableInternalOffers,
|
||||||
|
availableExternalOffers: stockCalculation.availableExternalOffers
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('🔍 Детали результата:')
|
console.log('📊 Детализация по предложениям:')
|
||||||
console.log('- Внутренние предложения:', result.internalOffers)
|
console.log(`- Внутренние предложения: ${result.internalOffers.length} (доступно: ${stockCalculation.availableInternalOffers}, общий сток: ${stockCalculation.totalInternalStock})`)
|
||||||
console.log('- Внешние предложения:', result.externalOffers.slice(0, 3))
|
console.log(`- Внешние предложения: ${result.externalOffers.length} (доступно: ${stockCalculation.availableExternalOffers}, общий сток: ${stockCalculation.totalExternalStock})`)
|
||||||
console.log('- Аналоги:', result.analogs.length)
|
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(
|
await saveSearchHistory(
|
||||||
context,
|
context,
|
||||||
`${cleanBrand} ${cleanArticleNumber}`,
|
`${cleanBrand} ${cleanArticleNumber}`,
|
||||||
@ -4145,6 +4211,27 @@ export const resolvers = {
|
|||||||
console.error('Ошибка получения баннера героя:', error)
|
console.error('Ошибка получения баннера героя:', error)
|
||||||
throw new 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('Не удалось удалить баннер героя')
|
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(
|
searchProductOffers(
|
||||||
articleNumber: String!,
|
articleNumber: String!,
|
||||||
brand: String!
|
brand: String!,
|
||||||
|
cartItems: [CartItemInput!]
|
||||||
): ProductOffersResult!
|
): ProductOffersResult!
|
||||||
getAnalogOffers(analogs: [AnalogOfferInput!]!): [AnalogProduct!]
|
getAnalogOffers(analogs: [AnalogOfferInput!]!): [AnalogProduct!]
|
||||||
getBrandsByCode(code: String!): BrandsByCodeResponse!
|
getBrandsByCode(code: String!): BrandsByCodeResponse!
|
||||||
@ -1060,6 +1061,9 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Новые поступления
|
# Новые поступления
|
||||||
newArrivals(limit: Int = 8): [Product!]!
|
newArrivals(limit: Int = 8): [Product!]!
|
||||||
|
|
||||||
|
# Корзина
|
||||||
|
getCart: Cart
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthPayload {
|
type AuthPayload {
|
||||||
@ -1271,6 +1275,19 @@ export const typeDefs = gql`
|
|||||||
createHeroBanner(input: HeroBannerInput!): HeroBanner!
|
createHeroBanner(input: HeroBannerInput!): HeroBanner!
|
||||||
updateHeroBanner(id: String!, input: HeroBannerUpdateInput!): HeroBanner!
|
updateHeroBanner(id: String!, input: HeroBannerUpdateInput!): HeroBanner!
|
||||||
deleteHeroBanner(id: String!): Boolean!
|
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 {
|
input LoginInput {
|
||||||
@ -1707,6 +1724,69 @@ export const typeDefs = gql`
|
|||||||
category: String
|
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 {
|
type ProductOffersResult {
|
||||||
articleNumber: String!
|
articleNumber: String!
|
||||||
@ -1722,6 +1802,19 @@ export const typeDefs = gql`
|
|||||||
analogs: [AnalogInfo!]!
|
analogs: [AnalogInfo!]!
|
||||||
hasInternalStock: Boolean!
|
hasInternalStock: Boolean!
|
||||||
totalOffers: Int!
|
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 {
|
type PartsIndexImage {
|
||||||
@ -1748,6 +1841,7 @@ export const typeDefs = gql`
|
|||||||
rating: Float
|
rating: Float
|
||||||
supplier: String!
|
supplier: String!
|
||||||
canPurchase: Boolean!
|
canPurchase: Boolean!
|
||||||
|
isInCart: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExternalOffer {
|
type ExternalOffer {
|
||||||
@ -1760,6 +1854,7 @@ export const typeDefs = gql`
|
|||||||
deliveryTime: Int!
|
deliveryTime: Int!
|
||||||
deliveryTimeMax: Int!
|
deliveryTimeMax: Int!
|
||||||
quantity: Int!
|
quantity: Int!
|
||||||
|
isInCart: Boolean!
|
||||||
warehouse: String!
|
warehouse: String!
|
||||||
warehouseName: String
|
warehouseName: String
|
||||||
rejects: Float
|
rejects: Float
|
||||||
@ -2342,4 +2437,58 @@ export const typeDefs = gql`
|
|||||||
isActive: Boolean
|
isActive: Boolean
|
||||||
sortOrder: Int
|
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(
|
async getCatalogEntities(
|
||||||
catalogId: string,
|
catalogId: string,
|
||||||
|
@ -5,6 +5,9 @@ CMS_PORT=3000
|
|||||||
# Подключение к внешней PostgreSQL базе
|
# Подключение к внешней PostgreSQL базе
|
||||||
DATABASE_URL=postgresql://username:password@your-db-host:5432/protekauto_cms
|
DATABASE_URL=postgresql://username:password@your-db-host:5432/protekauto_cms
|
||||||
|
|
||||||
|
# База данных для сохранения данных запчастей
|
||||||
|
PARTSDB_URL=postgresql://username:password@your-db-host:5432/protekauto_parts
|
||||||
|
|
||||||
# ===== АВТОРИЗАЦИЯ =====
|
# ===== АВТОРИЗАЦИЯ =====
|
||||||
# Секретный ключ для NextAuth (генерируйте случайно)
|
# Секретный ключ для NextAuth (генерируйте случайно)
|
||||||
NEXTAUTH_SECRET=your-super-secret-key-here-change-me
|
NEXTAUTH_SECRET=your-super-secret-key-here-change-me
|
||||||
|
Reference in New Issue
Block a user