Files
webapp/app/composables/useCatalogInfo.ts
Ruslan Bakiev 20e0e73c58
All checks were successful
Build Docker Image / build (push) Successful in 3m59s
refactor: remove any types and fix TypeScript errors
- Export InfoProductItem, InfoHubItem, InfoSupplierItem, InfoOfferItem types
- Update InfoEntity interface to have explicit fields (no index signature)
- Export CatalogHubItem, CatalogNearestHubItem from useCatalogHubs
- Fix MapItem interfaces to accept nullable GraphQL types
- Fix v-for :key bindings to handle null uuid
- Add null guards in select-location pages
- Update HubCard to accept nullable transportTypes
- Add shims.d.ts for missing module declarations
2026-01-27 10:35:14 +07:00

564 lines
17 KiB
TypeScript

import type { InfoEntityType } from './useCatalogSearch'
import type {
GetNodeQueryResult,
NearestHubsQueryResult,
NearestOffersQueryResult
} from '~/composables/graphql/public/geo-generated'
import {
GetNodeDocument,
NearestOffersDocument,
NearestHubsDocument
} from '~/composables/graphql/public/geo-generated'
import type {
GetOfferQueryResult,
GetSupplierProfileQueryResult
} from '~/composables/graphql/public/exchange-generated'
import {
GetOfferDocument,
GetSupplierProfileDocument
} from '~/composables/graphql/public/exchange-generated'
// Types from codegen
type NodeEntity = NonNullable<GetNodeQueryResult['node']>
type OfferEntity = NonNullable<GetOfferQueryResult['getOffer']>
type SupplierProfile = NonNullable<GetSupplierProfileQueryResult['getSupplierProfile']>
type HubItem = NonNullable<NonNullable<NearestHubsQueryResult['nearestHubs']>[number]>
type OfferItem = NonNullable<NonNullable<NearestOffersQueryResult['nearestOffers']>[number]>
// Product type (aggregated from offers)
export interface InfoProductItem {
uuid: string
name: string
offersCount?: number
}
// Re-export types for InfoPanel
export type InfoHubItem = HubItem
export type InfoSupplierItem = SupplierProfile
export type InfoOfferItem = OfferItem
// Extended entity type with all known fields (NO index signature!)
export interface InfoEntity {
uuid?: string | null
name?: string | null
// Node coordinates
latitude?: number | null
longitude?: number | null
// Location fields
address?: string | null
city?: string | null
country?: string | null
// Offer coordinates (different field names)
locationLatitude?: number | null
locationLongitude?: number | null
locationUuid?: string | null
locationName?: string | null
// Offer fields
productUuid?: string | null
productName?: string | null
teamUuid?: string | null
teamName?: string | null
pricePerUnit?: number | string | null
currency?: string | null
unit?: string | null
// Enriched field from supplier profile
supplierName?: string | null
}
// Helper to get coordinates from entity (handles both node and offer patterns)
function getEntityCoords(e: InfoEntity | null): { lat: number; lon: number } | null {
if (!e) return null
// Try offer coords first (locationLatitude/locationLongitude)
const lat = e.locationLatitude ?? e.latitude
const lon = e.locationLongitude ?? e.longitude
if (lat != null && lon != null) {
return { lat, lon }
}
return null
}
export function useCatalogInfo() {
const { execute } = useGraphQL()
// State with proper types
const entity = ref<InfoEntity | null>(null)
const entityType = ref<InfoEntityType | null>(null)
const relatedProducts = ref<InfoProductItem[]>([])
const relatedHubs = ref<HubItem[]>([])
const relatedSuppliers = ref<SupplierProfile[]>([])
const relatedOffers = ref<OfferItem[]>([])
const selectedProduct = ref<string | null>(null)
const activeTab = ref<string>('products')
const isLoading = ref(false)
// Separate loading states for each tab
const isLoadingProducts = ref(false)
const isLoadingHubs = ref(false)
const isLoadingSuppliers = ref(false)
const isLoadingOffers = ref(false)
// Load hub info: hub details + products + suppliers (in parallel)
const loadHubInfo = async (uuid: string) => {
try {
// Load hub node details
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
entity.value = nodeData?.node ?? null
const coords = getEntityCoords(entity.value)
if (!coords) {
console.warn('Hub has no coordinates')
return
}
// Set default active tab to offers (first step shows products)
activeTab.value = 'offers'
// Load products AND suppliers in parallel
isLoadingProducts.value = true
isLoadingSuppliers.value = true
// Load products (offers grouped by product) and extract suppliers
execute(
NearestOffersDocument,
{
lat: coords.lat,
lon: coords.lon,
radius: 500
},
'public',
'geo'
).then(offersData => {
// Group offers by product
const productsMap = new Map<string, InfoProductItem>()
const suppliersMap = new Map<string, { uuid: string; name: string; latitude?: number | null; longitude?: number | null }>()
offersData?.nearestOffers?.forEach(offer => {
if (!offer) return
// Products
if (offer.productUuid && offer.productName) {
const existing = productsMap.get(offer.productUuid)
if (existing) {
existing.offersCount = (existing.offersCount || 0) + 1
} else {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 1
})
}
}
// Suppliers (extract from offers)
if (offer.supplierUuid && !suppliersMap.has(offer.supplierUuid)) {
suppliersMap.set(offer.supplierUuid, {
uuid: offer.supplierUuid,
name: offer.supplierName || 'Supplier',
latitude: offer.latitude,
longitude: offer.longitude
})
}
})
relatedProducts.value = Array.from(productsMap.values())
// Load supplier profiles for detailed info
const supplierUuids = Array.from(suppliersMap.keys()).slice(0, 12)
if (supplierUuids.length > 0) {
Promise.all(
supplierUuids.map(supplierId =>
execute(GetSupplierProfileDocument, { uuid: supplierId }, 'public', 'exchange')
.then(data => data?.getSupplierProfile)
.catch(() => suppliersMap.get(supplierId)) // Fallback to basic info
)
).then(profiles => {
relatedSuppliers.value = profiles.filter((p): p is SupplierProfile => p != null)
isLoadingSuppliers.value = false
})
} else {
relatedSuppliers.value = []
isLoadingSuppliers.value = false
}
}).finally(() => {
isLoadingProducts.value = false
})
} catch (error) {
console.error('Error loading hub info:', error)
isLoadingProducts.value = false
isLoadingSuppliers.value = false
}
}
// Load supplier info: supplier details + products + hubs (in parallel)
const loadSupplierInfo = async (uuid: string) => {
try {
// Load supplier node details (might be geo node)
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
entity.value = nodeData?.node ?? null
// Also try to get supplier profile from exchange API for additional details
try {
const profileData = await execute(
GetSupplierProfileDocument,
{ uuid },
'public',
'exchange'
)
if (profileData?.getSupplierProfile) {
entity.value = { ...entity.value, ...profileData.getSupplierProfile }
}
} catch (e) {
// Supplier profile might not exist, ignore
}
if (!entity.value?.latitude || !entity.value?.longitude) {
console.warn('Supplier has no coordinates')
return
}
// Set default active tab to offers (first step shows products)
activeTab.value = 'offers'
// Load products AND hubs in parallel
isLoadingProducts.value = true
isLoadingHubs.value = true
// Load products (offers grouped by product)
execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
'public',
'geo'
).then(offersData => {
// Group offers by product
const productsMap = new Map<string, InfoProductItem>()
offersData?.nearestOffers?.forEach(offer => {
if (!offer || !offer.productUuid || !offer.productName) return
const existing = productsMap.get(offer.productUuid)
if (existing) {
existing.offersCount = (existing.offersCount || 0) + 1
} else {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName,
offersCount: 1
})
}
})
relatedProducts.value = Array.from(productsMap.values())
}).finally(() => {
isLoadingProducts.value = false
})
// Load hubs near supplier
execute(
NearestHubsDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 1000,
limit: 12
},
'public',
'geo'
).then(hubsData => {
relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null)
}).finally(() => {
isLoadingHubs.value = false
})
} catch (error) {
console.error('Error loading supplier info:', error)
isLoadingProducts.value = false
isLoadingHubs.value = false
}
}
// Load offer info: offer details + supplier + hubs
const loadOfferInfo = async (uuid: string) => {
try {
// Load offer details from exchange API
const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange')
entity.value = offerData?.getOffer ?? null
const coords = getEntityCoords(entity.value)
if (!coords) {
console.warn('Offer has no coordinates')
return
}
// Set default active tab to hubs
activeTab.value = 'hubs'
// Set product as "related product" (single item)
if (entity.value?.productUuid && entity.value?.productName) {
relatedProducts.value = [
{
uuid: entity.value.productUuid,
name: entity.value.productName
}
]
}
// Load hubs near offer coordinates
isLoadingHubs.value = true
execute(
NearestHubsDocument,
{
lat: coords.lat,
lon: coords.lon,
radius: 1000,
limit: 12
},
'public',
'geo'
).then(hubsData => {
relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null)
}).finally(() => {
isLoadingHubs.value = false
})
// If offer has supplier UUID, load supplier profile
if (entity.value?.teamUuid) {
isLoadingSuppliers.value = true
execute(
GetSupplierProfileDocument,
{ uuid: entity.value.teamUuid },
'public',
'exchange'
).then(supplierData => {
const supplier = supplierData?.getSupplierProfile
relatedSuppliers.value = supplier ? [supplier] : []
// Enrich entity with supplier name for display
if (supplier?.name && entity.value) {
entity.value = { ...entity.value, supplierName: supplier.name }
}
}).catch(() => {
// Supplier might not exist
}).finally(() => {
isLoadingSuppliers.value = false
})
}
} catch (error) {
console.error('Error loading offer info:', error)
}
}
// Load offers for hub after product selection
const loadOffersForHub = async (hubUuid: string, productUuid: string) => {
try {
const hub = entity.value
if (!hub?.latitude || !hub?.longitude) {
console.warn('Hub has no coordinates')
return
}
// Load offers
isLoadingOffers.value = true
isLoadingSuppliers.value = true
try {
// Find offers near hub for this product WITH routes calculated on backend
const offersData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
productUuid,
hubUuid, // Pass hubUuid to get routes calculated on backend
radius: 500,
limit: 12
},
'public',
'geo'
)
// Offers already include routes from backend
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null)
isLoadingOffers.value = false
// Extract unique suppliers from offers (use supplierUuid from offers)
const supplierUuids = new Set<string>()
relatedOffers.value.forEach(offer => {
if (offer.supplierUuid) {
supplierUuids.add(offer.supplierUuid)
}
})
// Load supplier profiles (limit to 12)
const suppliers: SupplierProfile[] = []
for (const uuid of Array.from(supplierUuids).slice(0, 12)) {
try {
const supplierData = await execute(
GetSupplierProfileDocument,
{ uuid },
'public',
'exchange'
)
if (supplierData?.getSupplierProfile) {
suppliers.push(supplierData.getSupplierProfile)
}
} catch (e) {
// Supplier might not exist
}
}
relatedSuppliers.value = suppliers
} finally {
isLoadingOffers.value = false
isLoadingSuppliers.value = false
}
} catch (error) {
console.error('Error loading offers for hub:', error)
}
}
// Load offers for supplier after product selection
const loadOffersForSupplier = async (_supplierUuid: string, productUuid: string) => {
try {
const supplier = entity.value
if (!supplier?.latitude || !supplier?.longitude) {
console.warn('Supplier has no coordinates')
return
}
isLoadingOffers.value = true
isLoadingHubs.value = true
try {
// Find offers near supplier for this product
const offersData = await execute(
NearestOffersDocument,
{
lat: supplier.latitude,
lon: supplier.longitude,
productUuid,
radius: 500,
limit: 12
},
'public',
'geo'
)
relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null)
isLoadingOffers.value = false
// Load hubs near each offer and aggregate (limit to 12)
const allHubs = new Map<string, HubItem>()
for (const offer of relatedOffers.value.slice(0, 3)) {
// Check first 3 offers
if (!offer.latitude || !offer.longitude) continue
try {
const hubsData = await execute(
NearestHubsDocument,
{
lat: offer.latitude,
lon: offer.longitude,
radius: 1000,
limit: 5
},
'public',
'geo'
)
hubsData?.nearestHubs?.forEach(hub => {
if (hub && hub.uuid && !allHubs.has(hub.uuid)) {
allHubs.set(hub.uuid, hub)
}
})
} catch (e) {
console.warn('Error loading hubs for offer:', offer.uuid, e)
}
if (allHubs.size >= 12) break
}
relatedHubs.value = Array.from(allHubs.values()).slice(0, 12)
} finally {
isLoadingOffers.value = false
isLoadingHubs.value = false
}
} catch (error) {
console.error('Error loading offers for supplier:', error)
}
}
// Select product (triggers offers loading)
const selectProduct = async (productUuid: string) => {
selectedProduct.value = productUuid
if (!entity.value) return
// Use stored entity type instead of inferring from properties
if (entityType.value === 'hub' && entity.value.uuid) {
await loadOffersForHub(entity.value.uuid, productUuid)
activeTab.value = 'offers'
} else if (entityType.value === 'supplier' && entity.value.uuid) {
await loadOffersForSupplier(entity.value.uuid, productUuid)
activeTab.value = 'offers'
}
}
// Set active tab
const setActiveTab = (tab: string) => {
activeTab.value = tab
}
// Main load method - dispatches to specific loaders
const loadInfo = async (type: InfoEntityType, uuid: string) => {
isLoading.value = true
clearInfo() // Clear previous data
entityType.value = type // Store entity type
try {
if (type === 'hub') {
await loadHubInfo(uuid)
} else if (type === 'supplier') {
await loadSupplierInfo(uuid)
} else if (type === 'offer') {
await loadOfferInfo(uuid)
}
} finally {
isLoading.value = false
}
}
// Clear all info data
const clearInfo = () => {
entity.value = null
entityType.value = null
relatedProducts.value = []
relatedHubs.value = []
relatedSuppliers.value = []
relatedOffers.value = []
selectedProduct.value = null
activeTab.value = 'products'
isLoadingProducts.value = false
isLoadingHubs.value = false
isLoadingSuppliers.value = false
isLoadingOffers.value = false
}
return {
// State
entity,
relatedProducts,
relatedHubs,
relatedSuppliers,
relatedOffers,
selectedProduct,
activeTab,
isLoading,
isLoadingProducts,
isLoadingHubs,
isLoadingSuppliers,
isLoadingOffers,
// Actions
loadInfo,
selectProduct,
setActiveTab,
clearInfo
}
}