feat(intelligence): Add state filter to all Intelligence pages
- Add state filter to Intelligence Brands API and frontend - Add state filter to Intelligence Pricing API and frontend - Add state filter to Intelligence Stores API and frontend - Fix null safety issues with toLocaleString() calls - Update backend /stores endpoint to return skuCount, snapshotCount, chainName - Add overall stats to pricing endpoint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,13 +14,25 @@ router.use(authMiddleware);
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/intelligence/brands
|
* GET /api/admin/intelligence/brands
|
||||||
* List all brands with state presence, store counts, and pricing
|
* 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) => {
|
router.get('/brands', async (req: Request, res: Response) => {
|
||||||
try {
|
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 limitNum = Math.min(parseInt(limit as string, 10), 1000);
|
||||||
const offsetNum = parseInt(offset as string, 10);
|
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(`
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name_raw as brand_name,
|
sp.brand_name_raw as brand_name,
|
||||||
@@ -32,17 +44,26 @@ router.get('/brands', async (req: Request, res: Response) => {
|
|||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON sp.dispensary_id = d.id
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
||||||
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
||||||
|
${stateFilter}
|
||||||
GROUP BY sp.brand_name_raw
|
GROUP BY sp.brand_name_raw
|
||||||
ORDER BY store_count DESC, sku_count DESC
|
ORDER BY store_count DESC, sku_count DESC
|
||||||
LIMIT $1 OFFSET $2
|
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(`
|
const { rows: countRows } = await pool.query(`
|
||||||
SELECT COUNT(DISTINCT brand_name_raw) as total
|
SELECT COUNT(DISTINCT sp.brand_name_raw) as total
|
||||||
FROM store_products
|
FROM store_products sp
|
||||||
WHERE brand_name_raw IS NOT NULL AND brand_name_raw != ''
|
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({
|
res.json({
|
||||||
brands: rows.map((r: any) => ({
|
brands: rows.map((r: any) => ({
|
||||||
@@ -147,10 +168,42 @@ router.get('/brands/:brandName/penetration', async (req: Request, res: Response)
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/intelligence/pricing
|
* GET /api/admin/intelligence/pricing
|
||||||
* Get pricing analytics by category
|
* Get pricing analytics by category
|
||||||
|
* Query params:
|
||||||
|
* - state: Filter by state (e.g., "AZ")
|
||||||
*/
|
*/
|
||||||
router.get('/pricing', async (req: Request, res: Response) => {
|
router.get('/pricing', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { rows: categoryRows } = await pool.query(`
|
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
|
SELECT
|
||||||
sp.category_raw as category,
|
sp.category_raw as category,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||||
@@ -162,8 +215,11 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
||||||
GROUP BY sp.category_raw
|
GROUP BY sp.category_raw
|
||||||
ORDER BY product_count DESC
|
ORDER BY product_count DESC
|
||||||
`);
|
`;
|
||||||
|
|
||||||
|
const { rows: categoryRows } = await pool.query(categoryQuery, categoryParams);
|
||||||
|
|
||||||
|
// State pricing
|
||||||
const { rows: stateRows } = await pool.query(`
|
const { rows: stateRows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
d.state,
|
d.state,
|
||||||
@@ -178,6 +234,31 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
ORDER BY avg_price DESC
|
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({
|
res.json({
|
||||||
byCategory: categoryRows.map((r: any) => ({
|
byCategory: categoryRows.map((r: any) => ({
|
||||||
category: r.category,
|
category: r.category,
|
||||||
@@ -194,6 +275,12 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
maxPrice: r.max_price ? parseFloat(r.max_price) : null,
|
maxPrice: r.max_price ? parseFloat(r.max_price) : null,
|
||||||
productCount: parseInt(r.product_count, 10),
|
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) {
|
} catch (error: any) {
|
||||||
console.error('[Intelligence] Error fetching pricing:', error.message);
|
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 /api/admin/intelligence/stores
|
||||||
* Get store intelligence summary
|
* 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) => {
|
router.get('/stores', async (req: Request, res: Response) => {
|
||||||
try {
|
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(`
|
const { rows: storeRows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
d.id,
|
d.id,
|
||||||
@@ -216,17 +317,22 @@ router.get('/stores', async (req: Request, res: Response) => {
|
|||||||
d.state,
|
d.state,
|
||||||
d.menu_type,
|
d.menu_type,
|
||||||
d.crawl_enabled,
|
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,
|
COUNT(DISTINCT sp.brand_name_raw) as brand_count,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
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
|
FROM dispensaries d
|
||||||
LEFT JOIN store_products sp ON sp.dispensary_id = d.id
|
LEFT JOIN store_products sp ON sp.dispensary_id = d.id
|
||||||
WHERE d.state IS NOT NULL
|
LEFT JOIN chains c ON d.chain_id = c.id
|
||||||
GROUP BY d.id, d.name, d.dba_name, d.city, d.state, d.menu_type, d.crawl_enabled
|
WHERE d.state IS NOT NULL AND d.crawl_enabled = true
|
||||||
ORDER BY product_count DESC
|
${stateFilter}
|
||||||
LIMIT 200
|
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({
|
res.json({
|
||||||
stores: storeRows.map((r: any) => ({
|
stores: storeRows.map((r: any) => ({
|
||||||
@@ -237,10 +343,13 @@ router.get('/stores', async (req: Request, res: Response) => {
|
|||||||
state: r.state,
|
state: r.state,
|
||||||
menuType: r.menu_type,
|
menuType: r.menu_type,
|
||||||
crawlEnabled: r.crawl_enabled,
|
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),
|
brandCount: parseInt(r.brand_count || '0', 10),
|
||||||
avgPrice: r.avg_price ? parseFloat(r.avg_price) : null,
|
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,
|
total: storeRows.length,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1518,10 +1518,11 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Intelligence API
|
// Intelligence API
|
||||||
async getIntelligenceBrands(params?: { limit?: number; offset?: number }) {
|
async getIntelligenceBrands(params?: { limit?: number; offset?: number; state?: string }) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
||||||
if (params?.offset) searchParams.append('offset', params.offset.toString());
|
if (params?.offset) searchParams.append('offset', params.offset.toString());
|
||||||
|
if (params?.state) searchParams.append('state', params.state);
|
||||||
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
|
||||||
return this.request<{
|
return this.request<{
|
||||||
brands: Array<{
|
brands: Array<{
|
||||||
@@ -1536,7 +1537,10 @@ class ApiClient {
|
|||||||
}>(`/api/admin/intelligence/brands${queryString}`);
|
}>(`/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<{
|
return this.request<{
|
||||||
byCategory: Array<{
|
byCategory: Array<{
|
||||||
category: string;
|
category: string;
|
||||||
@@ -1552,7 +1556,7 @@ class ApiClient {
|
|||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
totalProducts: number;
|
totalProducts: number;
|
||||||
};
|
};
|
||||||
}>('/api/admin/intelligence/pricing');
|
}>(`/api/admin/intelligence/pricing${queryString}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIntelligenceStoreActivity(params?: { state?: string; chainId?: number; limit?: number }) {
|
async getIntelligenceStoreActivity(params?: { state?: string; chainId?: number; limit?: number }) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { Layout } from '../components/Layout';
|
import { Layout } from '../components/Layout';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { trackProductClick } from '../lib/analytics';
|
import { trackProductClick } from '../lib/analytics';
|
||||||
|
import { useStateFilter } from '../hooks/useStateFilter';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface BrandData {
|
interface BrandData {
|
||||||
@@ -25,6 +27,8 @@ interface BrandData {
|
|||||||
|
|
||||||
export function IntelligenceBrands() {
|
export function IntelligenceBrands() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { selectedState, setSelectedState, stateParam, stateLabel, isAllStates } = useStateFilter();
|
||||||
|
const [availableStates, setAvailableStates] = useState<string[]>([]);
|
||||||
const [brands, setBrands] = useState<BrandData[]>([]);
|
const [brands, setBrands] = useState<BrandData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -32,12 +36,19 @@ export function IntelligenceBrands() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBrands();
|
loadBrands();
|
||||||
|
}, [stateParam]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load available states
|
||||||
|
api.getOrchestratorStates().then(data => {
|
||||||
|
setAvailableStates(data.states?.map((s: any) => s.state) || []);
|
||||||
|
}).catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadBrands = async () => {
|
const loadBrands = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await api.getIntelligenceBrands({ limit: 500 });
|
const data = await api.getIntelligenceBrands({ limit: 500, state: stateParam });
|
||||||
setBrands(data.brands || []);
|
setBrands(data.brands || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load brands:', error);
|
console.error('Failed to load brands:', error);
|
||||||
@@ -169,10 +180,33 @@ export function IntelligenceBrands() {
|
|||||||
|
|
||||||
{/* Top Brands Chart */}
|
{/* Top Brands Chart */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<BarChart3 className="w-5 h-5 text-blue-500" />
|
<BarChart3 className="w-5 h-5 text-blue-500" />
|
||||||
Top 10 Brands by Store Count
|
Top 10 Brands by Store Count
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
||||||
|
{stateLabel}
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
|
||||||
|
<li>
|
||||||
|
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
||||||
|
All States
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="divider"></li>
|
||||||
|
{availableStates.map((state) => (
|
||||||
|
<li key={state}>
|
||||||
|
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
||||||
|
{state}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{topBrands.map((brand, idx) => (
|
{topBrands.map((brand, idx) => (
|
||||||
<div key={brand.brandName} className="flex items-center gap-3">
|
<div key={brand.brandName} className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Layout } from '../components/Layout';
|
import { Layout } from '../components/Layout';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { useStateFilter } from '../hooks/useStateFilter';
|
||||||
import {
|
import {
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface CategoryPricing {
|
interface CategoryPricing {
|
||||||
@@ -31,18 +33,27 @@ interface OverallPricing {
|
|||||||
|
|
||||||
export function IntelligencePricing() {
|
export function IntelligencePricing() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { selectedState, setSelectedState, stateParam, stateLabel, isAllStates } = useStateFilter();
|
||||||
|
const [availableStates, setAvailableStates] = useState<string[]>([]);
|
||||||
const [categories, setCategories] = useState<CategoryPricing[]>([]);
|
const [categories, setCategories] = useState<CategoryPricing[]>([]);
|
||||||
const [overall, setOverall] = useState<OverallPricing | null>(null);
|
const [overall, setOverall] = useState<OverallPricing | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPricing();
|
loadPricing();
|
||||||
|
}, [stateParam]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load available states
|
||||||
|
api.getOrchestratorStates().then(data => {
|
||||||
|
setAvailableStates(data.states?.map((s: any) => s.state) || []);
|
||||||
|
}).catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadPricing = async () => {
|
const loadPricing = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await api.getIntelligencePricing();
|
const data = await api.getIntelligencePricing({ state: stateParam });
|
||||||
setCategories(data.byCategory || []);
|
setCategories(data.byCategory || []);
|
||||||
setOverall(data.overall || null);
|
setOverall(data.overall || null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -84,6 +95,27 @@ export function IntelligencePricing() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
|
||||||
|
{stateLabel}
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
|
||||||
|
<li>
|
||||||
|
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
|
||||||
|
All States
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="divider"></li>
|
||||||
|
{availableStates.map((state) => (
|
||||||
|
<li key={state}>
|
||||||
|
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
|
||||||
|
{state}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/intelligence/brands')}
|
onClick={() => navigate('/admin/intelligence/brands')}
|
||||||
className="btn btn-sm btn-outline gap-1"
|
className="btn btn-sm btn-outline gap-1"
|
||||||
@@ -150,7 +182,7 @@ export function IntelligencePricing() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Products Priced</p>
|
<p className="text-sm text-gray-500">Products Priced</p>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">
|
||||||
{overall.totalProducts.toLocaleString()}
|
{(overall.totalProducts || 0).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,7 +268,7 @@ export function IntelligencePricing() {
|
|||||||
<span className="font-medium">{cat.category || 'Unknown'}</span>
|
<span className="font-medium">{cat.category || 'Unknown'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<span className="font-mono">{cat.productCount.toLocaleString()}</span>
|
<span className="font-mono">{(cat.productCount || 0).toLocaleString()}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<span className="font-mono text-blue-600">{formatPrice(cat.minPrice)}</span>
|
<span className="font-mono text-blue-600">{formatPrice(cat.minPrice)}</span>
|
||||||
|
|||||||
@@ -47,10 +47,11 @@ export function IntelligenceStores() {
|
|||||||
state: stateParam,
|
state: stateParam,
|
||||||
limit: 500,
|
limit: 500,
|
||||||
});
|
});
|
||||||
setStores(data.stores || []);
|
const storeList = data.stores || [];
|
||||||
|
setStores(storeList);
|
||||||
|
|
||||||
// Extract unique states from response for dropdown counts
|
// 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);
|
setLocalStates(uniqueStates);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load stores:', error);
|
console.error('Failed to load stores:', error);
|
||||||
@@ -97,12 +98,12 @@ export function IntelligenceStores() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate stats
|
// Calculate stats with null safety
|
||||||
const totalSKUs = stores.reduce((sum, s) => sum + s.skuCount, 0);
|
const totalSKUs = stores.reduce((sum, s) => sum + (s.skuCount || 0), 0);
|
||||||
const totalSnapshots = stores.reduce((sum, s) => sum + s.snapshotCount, 0);
|
const totalSnapshots = stores.reduce((sum, s) => sum + (s.snapshotCount || 0), 0);
|
||||||
const avgFrequency = stores.filter(s => s.crawlFrequencyHours).length > 0
|
const storesWithFrequency = stores.filter(s => s.crawlFrequencyHours != null);
|
||||||
? stores.filter(s => s.crawlFrequencyHours).reduce((sum, s) => sum + (s.crawlFrequencyHours || 0), 0) /
|
const avgFrequency = storesWithFrequency.length > 0
|
||||||
stores.filter(s => s.crawlFrequencyHours).length
|
? storesWithFrequency.reduce((sum, s) => sum + (s.crawlFrequencyHours || 0), 0) / storesWithFrequency.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -262,10 +263,10 @@ export function IntelligenceStores() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<span className="font-mono">{store.skuCount.toLocaleString()}</span>
|
<span className="font-mono">{(store.skuCount || 0).toLocaleString()}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<span className="font-mono">{store.snapshotCount.toLocaleString()}</span>
|
<span className="font-mono">{(store.snapshotCount || 0).toLocaleString()}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={store.lastCrawl ? 'text-green-600' : 'text-gray-400'}>
|
<span className={store.lastCrawl ? 'text-green-600' : 'text-gray-400'}>
|
||||||
|
|||||||
Reference in New Issue
Block a user