diff --git a/backend/src/routes/intelligence.ts b/backend/src/routes/intelligence.ts index fb970965..8b13715d 100644 --- a/backend/src/routes/intelligence.ts +++ b/backend/src/routes/intelligence.ts @@ -14,13 +14,25 @@ router.use(authMiddleware); /** * GET /api/admin/intelligence/brands * List all brands with state presence, store counts, and pricing + * Query params: + * - state: Filter by state (e.g., "AZ") + * - limit: Max results (default 500) + * - offset: Pagination offset */ router.get('/brands', async (req: Request, res: Response) => { try { - const { limit = '500', offset = '0' } = req.query; + const { limit = '500', offset = '0', state } = req.query; const limitNum = Math.min(parseInt(limit as string, 10), 1000); const offsetNum = parseInt(offset as string, 10); + // Build WHERE clause based on state filter + let stateFilter = ''; + const params: any[] = [limitNum, offsetNum]; + if (state && state !== 'all') { + stateFilter = 'AND d.state = $3'; + params.push(state); + } + const { rows } = await pool.query(` SELECT sp.brand_name_raw as brand_name, @@ -32,17 +44,26 @@ router.get('/brands', async (req: Request, res: Response) => { FROM store_products sp JOIN dispensaries d ON sp.dispensary_id = d.id WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != '' + ${stateFilter} GROUP BY sp.brand_name_raw ORDER BY store_count DESC, sku_count DESC LIMIT $1 OFFSET $2 - `, [limitNum, offsetNum]); + `, params); - // Get total count + // Get total count with same state filter + const countParams: any[] = []; + let countStateFilter = ''; + if (state && state !== 'all') { + countStateFilter = 'AND d.state = $1'; + countParams.push(state); + } const { rows: countRows } = await pool.query(` - SELECT COUNT(DISTINCT brand_name_raw) as total - FROM store_products - WHERE brand_name_raw IS NOT NULL AND brand_name_raw != '' - `); + SELECT COUNT(DISTINCT sp.brand_name_raw) as total + FROM store_products sp + JOIN dispensaries d ON sp.dispensary_id = d.id + WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != '' + ${countStateFilter} + `, countParams); res.json({ brands: rows.map((r: any) => ({ @@ -147,23 +168,58 @@ router.get('/brands/:brandName/penetration', async (req: Request, res: Response) /** * GET /api/admin/intelligence/pricing * Get pricing analytics by category + * Query params: + * - state: Filter by state (e.g., "AZ") */ router.get('/pricing', async (req: Request, res: Response) => { try { - const { rows: categoryRows } = await pool.query(` - SELECT - sp.category_raw as category, - ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price, - MIN(sp.price_rec) as min_price, - MAX(sp.price_rec) as max_price, - ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price, - COUNT(*) as product_count - FROM store_products sp - WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0 - GROUP BY sp.category_raw - ORDER BY product_count DESC - `); + const { state } = req.query; + // Build WHERE clause based on state filter + let stateFilter = ''; + const categoryParams: any[] = []; + const stateQueryParams: any[] = []; + const overallParams: any[] = []; + + if (state && state !== 'all') { + stateFilter = 'AND d.state = $1'; + categoryParams.push(state); + overallParams.push(state); + } + + // Category pricing with optional state filter + const categoryQuery = state && state !== 'all' + ? ` + SELECT + sp.category_raw as category, + ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price, + MIN(sp.price_rec) as min_price, + MAX(sp.price_rec) as max_price, + ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price, + COUNT(*) as product_count + FROM store_products sp + JOIN dispensaries d ON sp.dispensary_id = d.id + WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0 ${stateFilter} + GROUP BY sp.category_raw + ORDER BY product_count DESC + ` + : ` + SELECT + sp.category_raw as category, + ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price, + MIN(sp.price_rec) as min_price, + MAX(sp.price_rec) as max_price, + ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price, + COUNT(*) as product_count + FROM store_products sp + WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0 + GROUP BY sp.category_raw + ORDER BY product_count DESC + `; + + const { rows: categoryRows } = await pool.query(categoryQuery, categoryParams); + + // State pricing const { rows: stateRows } = await pool.query(` SELECT d.state, @@ -178,6 +234,31 @@ router.get('/pricing', async (req: Request, res: Response) => { ORDER BY avg_price DESC `); + // Overall stats with optional state filter + const overallQuery = state && state !== 'all' + ? ` + SELECT + ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price, + MIN(sp.price_rec) as min_price, + MAX(sp.price_rec) as max_price, + COUNT(*) as total_products + FROM store_products sp + JOIN dispensaries d ON sp.dispensary_id = d.id + WHERE sp.price_rec > 0 ${stateFilter} + ` + : ` + SELECT + ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price, + MIN(sp.price_rec) as min_price, + MAX(sp.price_rec) as max_price, + COUNT(*) as total_products + FROM store_products sp + WHERE sp.price_rec > 0 + `; + + const { rows: overallRows } = await pool.query(overallQuery, overallParams); + const overall = overallRows[0]; + res.json({ byCategory: categoryRows.map((r: any) => ({ category: r.category, @@ -194,6 +275,12 @@ router.get('/pricing', async (req: Request, res: Response) => { maxPrice: r.max_price ? parseFloat(r.max_price) : null, productCount: parseInt(r.product_count, 10), })), + overall: { + avgPrice: overall?.avg_price ? parseFloat(overall.avg_price) : null, + minPrice: overall?.min_price ? parseFloat(overall.min_price) : null, + maxPrice: overall?.max_price ? parseFloat(overall.max_price) : null, + totalProducts: parseInt(overall?.total_products || '0', 10), + }, }); } catch (error: any) { console.error('[Intelligence] Error fetching pricing:', error.message); @@ -204,9 +291,23 @@ router.get('/pricing', async (req: Request, res: Response) => { /** * GET /api/admin/intelligence/stores * Get store intelligence summary + * Query params: + * - state: Filter by state (e.g., "AZ") + * - limit: Max results (default 200) */ router.get('/stores', async (req: Request, res: Response) => { try { + const { state, limit = '200' } = req.query; + const limitNum = Math.min(parseInt(limit as string, 10), 500); + + // Build WHERE clause based on state filter + let stateFilter = ''; + const params: any[] = [limitNum]; + if (state && state !== 'all') { + stateFilter = 'AND d.state = $2'; + params.push(state); + } + const { rows: storeRows } = await pool.query(` SELECT d.id, @@ -216,17 +317,22 @@ router.get('/stores', async (req: Request, res: Response) => { d.state, d.menu_type, d.crawl_enabled, - COUNT(DISTINCT sp.id) as product_count, + c.name as chain_name, + COUNT(DISTINCT sp.id) as sku_count, COUNT(DISTINCT sp.brand_name_raw) as brand_count, ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price, - MAX(sp.updated_at) as last_product_update + MAX(sp.updated_at) as last_crawl, + (SELECT COUNT(*) FROM store_product_snapshots sps + WHERE sps.store_product_id IN (SELECT id FROM store_products WHERE dispensary_id = d.id)) as snapshot_count FROM dispensaries d LEFT JOIN store_products sp ON sp.dispensary_id = d.id - WHERE d.state IS NOT NULL - GROUP BY d.id, d.name, d.dba_name, d.city, d.state, d.menu_type, d.crawl_enabled - ORDER BY product_count DESC - LIMIT 200 - `); + LEFT JOIN chains c ON d.chain_id = c.id + WHERE d.state IS NOT NULL AND d.crawl_enabled = true + ${stateFilter} + GROUP BY d.id, d.name, d.dba_name, d.city, d.state, d.menu_type, d.crawl_enabled, c.name + ORDER BY sku_count DESC + LIMIT $1 + `, params); res.json({ stores: storeRows.map((r: any) => ({ @@ -237,10 +343,13 @@ router.get('/stores', async (req: Request, res: Response) => { state: r.state, menuType: r.menu_type, crawlEnabled: r.crawl_enabled, - productCount: parseInt(r.product_count || '0', 10), + chainName: r.chain_name || null, + skuCount: parseInt(r.sku_count || '0', 10), + snapshotCount: parseInt(r.snapshot_count || '0', 10), brandCount: parseInt(r.brand_count || '0', 10), avgPrice: r.avg_price ? parseFloat(r.avg_price) : null, - lastProductUpdate: r.last_product_update, + lastCrawl: r.last_crawl, + crawlFrequencyHours: 4, // Default crawl frequency })), total: storeRows.length, }); diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index 3170d7bd..d8b7485c 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -1518,10 +1518,11 @@ class ApiClient { } // Intelligence API - async getIntelligenceBrands(params?: { limit?: number; offset?: number }) { + async getIntelligenceBrands(params?: { limit?: number; offset?: number; state?: string }) { const searchParams = new URLSearchParams(); if (params?.limit) searchParams.append('limit', params.limit.toString()); if (params?.offset) searchParams.append('offset', params.offset.toString()); + if (params?.state) searchParams.append('state', params.state); const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; return this.request<{ brands: Array<{ @@ -1536,7 +1537,10 @@ class ApiClient { }>(`/api/admin/intelligence/brands${queryString}`); } - async getIntelligencePricing() { + async getIntelligencePricing(params?: { state?: string }) { + const searchParams = new URLSearchParams(); + if (params?.state) searchParams.append('state', params.state); + const queryString = searchParams.toString() ? `?${searchParams.toString()}` : ''; return this.request<{ byCategory: Array<{ category: string; @@ -1552,7 +1556,7 @@ class ApiClient { maxPrice: number; totalProducts: number; }; - }>('/api/admin/intelligence/pricing'); + }>(`/api/admin/intelligence/pricing${queryString}`); } async getIntelligenceStoreActivity(params?: { state?: string; chainId?: number; limit?: number }) { diff --git a/cannaiq/src/pages/IntelligenceBrands.tsx b/cannaiq/src/pages/IntelligenceBrands.tsx index 237d91a5..ef6f2a25 100644 --- a/cannaiq/src/pages/IntelligenceBrands.tsx +++ b/cannaiq/src/pages/IntelligenceBrands.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Layout } from '../components/Layout'; import { api } from '../lib/api'; import { trackProductClick } from '../lib/analytics'; +import { useStateFilter } from '../hooks/useStateFilter'; import { Building2, MapPin, @@ -12,6 +13,7 @@ import { Search, TrendingUp, BarChart3, + ChevronDown, } from 'lucide-react'; interface BrandData { @@ -25,6 +27,8 @@ interface BrandData { export function IntelligenceBrands() { const navigate = useNavigate(); + const { selectedState, setSelectedState, stateParam, stateLabel, isAllStates } = useStateFilter(); + const [availableStates, setAvailableStates] = useState([]); const [brands, setBrands] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); @@ -32,12 +36,19 @@ export function IntelligenceBrands() { useEffect(() => { loadBrands(); + }, [stateParam]); + + useEffect(() => { + // Load available states + api.getOrchestratorStates().then(data => { + setAvailableStates(data.states?.map((s: any) => s.state) || []); + }).catch(console.error); }, []); const loadBrands = async () => { try { setLoading(true); - const data = await api.getIntelligenceBrands({ limit: 500 }); + const data = await api.getIntelligenceBrands({ limit: 500, state: stateParam }); setBrands(data.brands || []); } catch (error) { console.error('Failed to load brands:', error); @@ -169,10 +180,33 @@ export function IntelligenceBrands() { {/* Top Brands Chart */}
-

- - Top 10 Brands by Store Count -

+
+

+ + Top 10 Brands by Store Count +

+
+ + +
+
{topBrands.map((brand, idx) => (
diff --git a/cannaiq/src/pages/IntelligencePricing.tsx b/cannaiq/src/pages/IntelligencePricing.tsx index 68b97308..9b4425e9 100644 --- a/cannaiq/src/pages/IntelligencePricing.tsx +++ b/cannaiq/src/pages/IntelligencePricing.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Layout } from '../components/Layout'; import { api } from '../lib/api'; +import { useStateFilter } from '../hooks/useStateFilter'; import { DollarSign, Building2, @@ -11,6 +12,7 @@ import { TrendingUp, TrendingDown, BarChart3, + ChevronDown, } from 'lucide-react'; interface CategoryPricing { @@ -31,18 +33,27 @@ interface OverallPricing { export function IntelligencePricing() { const navigate = useNavigate(); + const { selectedState, setSelectedState, stateParam, stateLabel, isAllStates } = useStateFilter(); + const [availableStates, setAvailableStates] = useState([]); const [categories, setCategories] = useState([]); const [overall, setOverall] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { loadPricing(); + }, [stateParam]); + + useEffect(() => { + // Load available states + api.getOrchestratorStates().then(data => { + setAvailableStates(data.states?.map((s: any) => s.state) || []); + }).catch(console.error); }, []); const loadPricing = async () => { try { setLoading(true); - const data = await api.getIntelligencePricing(); + const data = await api.getIntelligencePricing({ state: stateParam }); setCategories(data.byCategory || []); setOverall(data.overall || null); } catch (error) { @@ -84,6 +95,27 @@ export function IntelligencePricing() {

+
@@ -236,7 +268,7 @@ export function IntelligencePricing() { {cat.category || 'Unknown'} - {cat.productCount.toLocaleString()} + {(cat.productCount || 0).toLocaleString()} {formatPrice(cat.minPrice)} diff --git a/cannaiq/src/pages/IntelligenceStores.tsx b/cannaiq/src/pages/IntelligenceStores.tsx index 1db64e91..6a098c08 100644 --- a/cannaiq/src/pages/IntelligenceStores.tsx +++ b/cannaiq/src/pages/IntelligenceStores.tsx @@ -47,10 +47,11 @@ export function IntelligenceStores() { state: stateParam, limit: 500, }); - setStores(data.stores || []); + const storeList = data.stores || []; + setStores(storeList); // Extract unique states from response for dropdown counts - const uniqueStates = [...new Set(data.stores.map((s: StoreActivity) => s.state))].sort(); + const uniqueStates = [...new Set(storeList.map((s: StoreActivity) => s.state))].filter(Boolean).sort() as string[]; setLocalStates(uniqueStates); } catch (error) { console.error('Failed to load stores:', error); @@ -97,12 +98,12 @@ export function IntelligenceStores() { ); } - // Calculate stats - const totalSKUs = stores.reduce((sum, s) => sum + s.skuCount, 0); - const totalSnapshots = stores.reduce((sum, s) => sum + s.snapshotCount, 0); - const avgFrequency = stores.filter(s => s.crawlFrequencyHours).length > 0 - ? stores.filter(s => s.crawlFrequencyHours).reduce((sum, s) => sum + (s.crawlFrequencyHours || 0), 0) / - stores.filter(s => s.crawlFrequencyHours).length + // Calculate stats with null safety + const totalSKUs = stores.reduce((sum, s) => sum + (s.skuCount || 0), 0); + const totalSnapshots = stores.reduce((sum, s) => sum + (s.snapshotCount || 0), 0); + const storesWithFrequency = stores.filter(s => s.crawlFrequencyHours != null); + const avgFrequency = storesWithFrequency.length > 0 + ? storesWithFrequency.reduce((sum, s) => sum + (s.crawlFrequencyHours || 0), 0) / storesWithFrequency.length : 0; return ( @@ -262,10 +263,10 @@ export function IntelligenceStores() { )} - {store.skuCount.toLocaleString()} + {(store.skuCount || 0).toLocaleString()} - {store.snapshotCount.toLocaleString()} + {(store.snapshotCount || 0).toLocaleString()}