fix(monitor): remove non-existent worker columns from job_run_logs query

The job_run_logs table tracks scheduled job orchestration, not individual
worker jobs. Worker info (worker_id, worker_hostname) belongs on
dispensary_crawl_jobs, not job_run_logs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-03 18:45:05 -07:00
parent 54f40d26bb
commit 66e07b2009
466 changed files with 84988 additions and 9226 deletions

View File

@@ -23,6 +23,7 @@ import { ApiPermissions } from './pages/ApiPermissions';
import { DutchieAZSchedule } from './pages/DutchieAZSchedule';
import { DutchieAZStores } from './pages/DutchieAZStores';
import { DutchieAZStoreDetail } from './pages/DutchieAZStoreDetail';
import { WholesaleAnalytics } from './pages/WholesaleAnalytics';
import { PrivateRoute } from './components/PrivateRoute';
export default function App() {
@@ -53,6 +54,7 @@ export default function App() {
<Route path="/az" element={<PrivateRoute><DutchieAZStores /></PrivateRoute>} />
<Route path="/az/stores/:id" element={<PrivateRoute><DutchieAZStoreDetail /></PrivateRoute>} />
<Route path="/api-permissions" element={<PrivateRoute><ApiPermissions /></PrivateRoute>} />
<Route path="/wholesale-analytics" element={<PrivateRoute><WholesaleAnalytics /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>

View File

@@ -164,6 +164,12 @@ export function Layout({ children }: LayoutProps) {
</NavSection>
<NavSection title="AZ Data">
<NavLink
to="/wholesale-analytics"
icon={<TrendingUp className="w-4 h-4" />}
label="Wholesale Analytics"
isActive={isActive('/wholesale-analytics')}
/>
<NavLink
to="/az"
icon={<Store className="w-4 h-4" />}

View File

@@ -344,6 +344,56 @@ class ApiClient {
return this.request<any>(`/api/scraper-monitor/jobs/workers${params}`);
}
// AZ Monitor (Dutchie AZ live crawler status)
async getAZMonitorActiveJobs() {
return this.request<{
scheduledJobs: any[];
crawlJobs: any[];
inMemoryScrapers: any[];
totalActive: number;
}>('/api/az/monitor/active-jobs');
}
async getAZMonitorRecentJobs(limit?: number) {
const params = limit ? `?limit=${limit}` : '';
return this.request<{
jobLogs: any[];
crawlJobs: any[];
}>(`/api/az/monitor/recent-jobs${params}`);
}
async getAZMonitorErrors(params?: { limit?: number; hours?: number }) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.hours) searchParams.append('hours', params.hours.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{ errors: any[] }>(`/api/az/monitor/errors${queryString}`);
}
async getAZMonitorSummary() {
return this.request<{
running_scheduled_jobs: number;
running_crawl_jobs: number;
successful_jobs_24h: number;
failed_jobs_24h: number;
successful_crawls_24h: number;
failed_crawls_24h: number;
products_found_24h: number;
snapshots_created_24h: number;
last_job_started: string | null;
last_job_completed: string | null;
nextRuns: Array<{
id: number;
job_name: string;
description: string;
enabled: boolean;
next_run_at: string;
last_status: string;
last_run_at: string;
}>;
}>('/api/az/monitor/summary');
}
// Change Approval
async getChanges(status?: 'pending' | 'approved' | 'rejected') {
const params = status ? `?status=${status}` : '';
@@ -402,7 +452,7 @@ class ApiClient {
return this.request<{ dispensaries: Array<{ id: number; name: string }> }>('/api/api-permissions/dispensaries');
}
async createApiPermission(data: { user_name: string; dispensary_id: number; allowed_ips?: string; allowed_domains?: string }) {
async createApiPermission(data: { user_name: string; store_id: number; allowed_ips?: string; allowed_domains?: string }) {
return this.request<{ permission: any; message: string }>('/api/api-permissions', {
method: 'POST',
body: JSON.stringify(data),
@@ -828,6 +878,39 @@ class ApiClient {
}>(`/api/az/stores/${id}/categories`);
}
// Dutchie AZ Global Brands/Categories (from v_brands/v_categories views)
async getDutchieAZBrands(params?: { limit?: number; offset?: number }) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
brands: Array<{
brand_name: string;
brand_id: string | null;
brand_logo_url: string | null;
product_count: number;
dispensary_count: number;
product_types: string[];
}>;
}>(`/api/az/brands${queryString}`);
}
async getDutchieAZCategories() {
return this.request<{
categories: Array<{
type: string;
subcategory: string | null;
product_count: number;
dispensary_count: number;
brand_count: number;
avg_thc: number | null;
min_thc: number | null;
max_thc: number | null;
}>;
}>('/api/az/categories');
}
// Dutchie AZ Debug
async getDutchieAZDebugSummary() {
return this.request<{
@@ -893,6 +976,65 @@ class ApiClient {
body: JSON.stringify(options || {}),
});
}
// Dutchie AZ Menu Detection
async getDetectionStats() {
return this.request<{
totalDispensaries: number;
withMenuType: number;
withPlatformId: number;
needsDetection: number;
byProvider: Record<string, number>;
}>('/api/az/admin/detection/stats');
}
async getDispensariesNeedingDetection(params?: { state?: string; limit?: number }) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.limit) searchParams.append('limit', params.limit.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{ dispensaries: any[]; total: number }>(`/api/az/admin/detection/pending${queryString}`);
}
async detectDispensary(id: number) {
return this.request<{
dispensaryId: number;
dispensaryName: string;
previousMenuType: string | null;
detectedProvider: string;
cName: string | null;
platformDispensaryId: string | null;
success: boolean;
error?: string;
}>(`/api/az/admin/detection/detect/${id}`, {
method: 'POST',
});
}
async detectAllDispensaries(options?: {
state?: string;
onlyUnknown?: boolean;
onlyMissingPlatformId?: boolean;
limit?: number;
}) {
return this.request<{
totalProcessed: number;
totalSucceeded: number;
totalFailed: number;
totalSkipped: number;
results: any[];
errors: string[];
}>('/api/az/admin/detection/detect-all', {
method: 'POST',
body: JSON.stringify(options || {}),
});
}
async triggerMenuDetectionJob() {
return this.request<{ success: boolean; message: string }>('/api/az/admin/detection/trigger', {
method: 'POST',
});
}
}
export const api = new ApiClient(API_URL);

View File

@@ -12,8 +12,8 @@ interface ApiPermission {
is_active: number;
created_at: string;
last_used_at: string | null;
dispensary_id: number | null;
dispensary_name: string | null;
store_id: number | null;
store_name: string | null;
}
interface Dispensary {
@@ -28,7 +28,7 @@ export function ApiPermissions() {
const [showAddForm, setShowAddForm] = useState(false);
const [newPermission, setNewPermission] = useState({
user_name: '',
dispensary_id: '',
store_id: '',
allowed_ips: '',
allowed_domains: '',
});
@@ -68,18 +68,18 @@ export function ApiPermissions() {
return;
}
if (!newPermission.dispensary_id) {
setNotification({ message: 'Dispensary is required', type: 'error' });
if (!newPermission.store_id) {
setNotification({ message: 'Store is required', type: 'error' });
return;
}
try {
const result = await api.createApiPermission({
...newPermission,
dispensary_id: parseInt(newPermission.dispensary_id),
store_id: parseInt(newPermission.store_id),
});
setNotification({ message: result.message, type: 'success' });
setNewPermission({ user_name: '', dispensary_id: '', allowed_ips: '', allowed_domains: '' });
setNewPermission({ user_name: '', store_id: '', allowed_ips: '', allowed_domains: '' });
setShowAddForm(false);
loadPermissions();
} catch (error: any) {
@@ -182,22 +182,22 @@ export function ApiPermissions() {
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Dispensary *
Store *
</label>
<select
value={newPermission.dispensary_id}
onChange={(e) => setNewPermission({ ...newPermission, dispensary_id: e.target.value })}
value={newPermission.store_id}
onChange={(e) => setNewPermission({ ...newPermission, store_id: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select a dispensary...</option>
<option value="">Select a store...</option>
{dispensaries.map((dispensary) => (
<option key={dispensary.id} value={dispensary.id}>
{dispensary.name}
</option>
))}
</select>
<p className="text-sm text-gray-600 mt-1">The dispensary this API token can access</p>
<p className="text-sm text-gray-600 mt-1">The store this API token can access</p>
</div>
<div className="mb-4">
@@ -261,7 +261,7 @@ export function ApiPermissions() {
User Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dispensary
Store
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
API Key
@@ -290,7 +290,7 @@ export function ApiPermissions() {
<div className="font-medium text-gray-900">{perm.user_name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{perm.dispensary_name || <span className="text-gray-400 italic">No dispensary</span>}</div>
<div className="text-sm text-gray-900">{perm.store_name || <span className="text-gray-400 italic">No store</span>}</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center space-x-2">

View File

@@ -35,15 +35,26 @@ interface RunLog {
created_at: string;
}
interface DetectionStats {
totalDispensaries: number;
withMenuType: number;
withPlatformId: number;
needsDetection: number;
byProvider: Record<string, number>;
}
export function DutchieAZSchedule() {
const [schedules, setSchedules] = useState<JobSchedule[]>([]);
const [runLogs, setRunLogs] = useState<RunLog[]>([]);
const [schedulerStatus, setSchedulerStatus] = useState<{ running: boolean; pollIntervalMs: number } | null>(null);
const [detectionStats, setDetectionStats] = useState<DetectionStats | null>(null);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<'schedules' | 'logs'>('schedules');
const [activeTab, setActiveTab] = useState<'schedules' | 'logs' | 'detection'>('schedules');
const [editingSchedule, setEditingSchedule] = useState<JobSchedule | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [detectingAll, setDetectingAll] = useState(false);
const [detectionResults, setDetectionResults] = useState<any>(null);
useEffect(() => {
loadData();
@@ -56,15 +67,17 @@ export function DutchieAZSchedule() {
const loadData = async () => {
try {
const [schedulesData, logsData, statusData] = await Promise.all([
const [schedulesData, logsData, statusData, detectionData] = await Promise.all([
api.getDutchieAZSchedules(),
api.getDutchieAZRunLogs({ limit: 50 }),
api.getDutchieAZSchedulerStatus(),
api.getDetectionStats().catch(() => null),
]);
setSchedules(schedulesData.schedules || []);
setRunLogs(logsData.logs || []);
setSchedulerStatus(statusData);
setDetectionStats(detectionData);
} catch (error) {
console.error('Failed to load schedule data:', error);
} finally {
@@ -139,6 +152,36 @@ export function DutchieAZSchedule() {
}
};
const handleDetectAll = async () => {
if (!confirm('Run menu detection on all dispensaries with unknown/missing menu_type?')) return;
setDetectingAll(true);
setDetectionResults(null);
try {
const result = await api.detectAllDispensaries({ state: 'AZ', onlyUnknown: true });
setDetectionResults(result);
await loadData();
} catch (error) {
console.error('Failed to run bulk detection:', error);
} finally {
setDetectingAll(false);
}
};
const handleDetectMissingIds = async () => {
if (!confirm('Resolve platform IDs for all Dutchie dispensaries missing them?')) return;
setDetectingAll(true);
setDetectionResults(null);
try {
const result = await api.detectAllDispensaries({ state: 'AZ', onlyMissingPlatformId: true, onlyUnknown: false });
setDetectionResults(result);
await loadData();
} catch (error) {
console.error('Failed to resolve platform IDs:', error);
} finally {
setDetectingAll(false);
}
};
const formatTimeAgo = (dateString: string | null) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
@@ -334,6 +377,22 @@ export function DutchieAZSchedule() {
>
Run Logs ({runLogs.length})
</button>
<button
onClick={() => setActiveTab('detection')}
style={{
padding: '12px 24px',
background: activeTab === 'detection' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'detection' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'detection' ? '600' : '400',
color: activeTab === 'detection' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Menu Detection {detectionStats?.needsDetection ? `(${detectionStats.needsDetection} pending)` : ''}
</button>
</div>
{activeTab === 'schedules' && (
@@ -574,6 +633,150 @@ export function DutchieAZSchedule() {
</div>
)}
{activeTab === 'detection' && (
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
padding: '30px'
}}>
{/* Detection Stats */}
{detectionStats && (
<div style={{ marginBottom: '30px' }}>
<h3 style={{ margin: '0 0 20px 0' }}>Detection Statistics</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '20px' }}>
<div style={{ padding: '20px', background: '#f8f8f8', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#2563eb' }}>{detectionStats.totalDispensaries}</div>
<div style={{ color: '#666', marginTop: '4px' }}>Total Dispensaries</div>
</div>
<div style={{ padding: '20px', background: '#f8f8f8', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#10b981' }}>{detectionStats.withMenuType}</div>
<div style={{ color: '#666', marginTop: '4px' }}>With Menu Type</div>
</div>
<div style={{ padding: '20px', background: '#f8f8f8', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#10b981' }}>{detectionStats.withPlatformId}</div>
<div style={{ color: '#666', marginTop: '4px' }}>With Platform ID</div>
</div>
<div style={{ padding: '20px', background: '#fef3c7', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '32px', fontWeight: '700', color: '#92400e' }}>{detectionStats.needsDetection}</div>
<div style={{ color: '#666', marginTop: '4px' }}>Needs Detection</div>
</div>
</div>
{/* Provider Breakdown */}
{Object.keys(detectionStats.byProvider).length > 0 && (
<div style={{ marginTop: '20px' }}>
<h4 style={{ margin: '0 0 10px 0' }}>By Provider</h4>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px' }}>
{Object.entries(detectionStats.byProvider).map(([provider, count]) => (
<span key={provider} style={{
padding: '6px 14px',
background: provider === 'dutchie' ? '#dbeafe' : '#f3f4f6',
borderRadius: '16px',
fontSize: '14px',
fontWeight: '600'
}}>
{provider}: {count}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Actions */}
<div style={{ marginBottom: '30px', display: 'flex', gap: '15px', flexWrap: 'wrap' }}>
<button
onClick={handleDetectAll}
disabled={detectingAll || !detectionStats?.needsDetection}
style={{
padding: '12px 24px',
background: detectingAll ? '#94a3b8' : '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: detectingAll ? 'not-allowed' : 'pointer',
fontWeight: '600',
fontSize: '14px'
}}
>
{detectingAll ? 'Detecting...' : 'Detect All Unknown'}
</button>
<button
onClick={handleDetectMissingIds}
disabled={detectingAll}
style={{
padding: '12px 24px',
background: detectingAll ? '#94a3b8' : '#10b981',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: detectingAll ? 'not-allowed' : 'pointer',
fontWeight: '600',
fontSize: '14px'
}}
>
{detectingAll ? 'Resolving...' : 'Resolve Missing Platform IDs'}
</button>
</div>
{/* Detection Results */}
{detectionResults && (
<div style={{ marginBottom: '30px', padding: '20px', background: '#f8f8f8', borderRadius: '8px' }}>
<h4 style={{ margin: '0 0 15px 0' }}>Detection Results</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '15px', marginBottom: '15px' }}>
<div>
<div style={{ fontWeight: '600', fontSize: '24px' }}>{detectionResults.totalProcessed}</div>
<div style={{ color: '#666', fontSize: '13px' }}>Processed</div>
</div>
<div>
<div style={{ fontWeight: '600', fontSize: '24px', color: '#10b981' }}>{detectionResults.totalSucceeded}</div>
<div style={{ color: '#666', fontSize: '13px' }}>Succeeded</div>
</div>
<div>
<div style={{ fontWeight: '600', fontSize: '24px', color: '#ef4444' }}>{detectionResults.totalFailed}</div>
<div style={{ color: '#666', fontSize: '13px' }}>Failed</div>
</div>
<div>
<div style={{ fontWeight: '600', fontSize: '24px', color: '#666' }}>{detectionResults.totalSkipped}</div>
<div style={{ color: '#666', fontSize: '13px' }}>Skipped</div>
</div>
</div>
{detectionResults.errors && detectionResults.errors.length > 0 && (
<div style={{ marginTop: '15px' }}>
<div style={{ fontWeight: '600', marginBottom: '8px', color: '#991b1b' }}>Errors:</div>
<div style={{ maxHeight: '150px', overflow: 'auto', background: '#fee2e2', padding: '10px', borderRadius: '4px', fontSize: '12px' }}>
{detectionResults.errors.slice(0, 10).map((error: string, i: number) => (
<div key={i} style={{ marginBottom: '4px' }}>{error}</div>
))}
{detectionResults.errors.length > 10 && (
<div style={{ fontStyle: 'italic', marginTop: '8px' }}>...and {detectionResults.errors.length - 10} more</div>
)}
</div>
</div>
)}
</div>
)}
{/* Info */}
<div style={{ padding: '20px', background: '#f0f9ff', borderRadius: '8px', fontSize: '14px' }}>
<h4 style={{ margin: '0 0 10px 0', color: '#1e40af' }}>About Menu Detection</h4>
<ul style={{ margin: 0, paddingLeft: '20px', color: '#1e40af' }}>
<li style={{ marginBottom: '8px' }}>
<strong>Detect All Unknown:</strong> Scans dispensaries with no menu_type set and detects the provider (dutchie, treez, jane, etc.) from their menu_url.
</li>
<li style={{ marginBottom: '8px' }}>
<strong>Resolve Missing Platform IDs:</strong> For dispensaries already detected as "dutchie", extracts the cName from menu_url and resolves the platform_dispensary_id via GraphQL.
</li>
<li>
<strong>Automatic scheduling:</strong> A "Menu Detection" job runs daily (24h +/- 1h jitter) to detect new dispensaries.
</li>
</ul>
</div>
</div>
)}
{/* Edit Modal */}
{editingSchedule && (
<div style={{

View File

@@ -14,6 +14,7 @@ import {
export function DutchieAZStores() {
const navigate = useNavigate();
const [stores, setStores] = useState<any[]>([]);
const [totalStores, setTotalStores] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [dashboard, setDashboard] = useState<any>(null);
@@ -25,10 +26,11 @@ export function DutchieAZStores() {
setLoading(true);
try {
const [storesData, dashboardData] = await Promise.all([
api.getDutchieAZStores({ limit: 100 }),
api.getDutchieAZStores({ limit: 200 }),
api.getDutchieAZDashboard(),
]);
setStores(storesData.stores);
setTotalStores(storesData.total);
setDashboard(dashboardData);
} catch (error) {
console.error('Failed to load data:', error);
@@ -124,7 +126,7 @@ export function DutchieAZStores() {
{/* Stores List */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">All Stores ({stores.length})</h2>
<h2 className="text-lg font-semibold text-gray-900">All Stores ({totalStores})</h2>
</div>
<div className="overflow-x-auto">
<table className="table table-zebra w-full">

View File

@@ -11,10 +11,16 @@ export function ScraperMonitor() {
const [recentJobs, setRecentJobs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<'scrapers' | 'jobs'>('jobs');
const [activeTab, setActiveTab] = useState<'az-live' | 'jobs' | 'scrapers'>('az-live');
const [selectedWorker, setSelectedWorker] = useState<string | null>(null);
const [workerLogs, setWorkerLogs] = useState<string>('');
// AZ Crawler state
const [azSummary, setAzSummary] = useState<any>(null);
const [azActiveJobs, setAzActiveJobs] = useState<any>({ scheduledJobs: [], crawlJobs: [], inMemoryScrapers: [], totalActive: 0 });
const [azRecentJobs, setAzRecentJobs] = useState<any>({ jobLogs: [], crawlJobs: [] });
const [azErrors, setAzErrors] = useState<any[]>([]);
useEffect(() => {
loadData();
@@ -41,6 +47,19 @@ export function ScraperMonitor() {
setActiveJobs(jobsData.jobs || []);
setWorkers(workersData.workers || []);
setRecentJobs(recentJobsData.jobs || []);
// Load AZ monitor data
const [azSummaryData, azActiveData, azRecentData, azErrorsData] = await Promise.all([
api.getAZMonitorSummary().catch(() => null),
api.getAZMonitorActiveJobs().catch(() => ({ scheduledJobs: [], crawlJobs: [], inMemoryScrapers: [], totalActive: 0 })),
api.getAZMonitorRecentJobs(30).catch(() => ({ jobLogs: [], crawlJobs: [] })),
api.getAZMonitorErrors({ limit: 10, hours: 24 }).catch(() => ({ errors: [] })),
]);
setAzSummary(azSummaryData);
setAzActiveJobs(azActiveData);
setAzRecentJobs(azRecentData);
setAzErrors(azErrorsData?.errors || []);
} catch (error) {
console.error('Failed to load scraper data:', error);
} finally {
@@ -79,6 +98,22 @@ export function ScraperMonitor() {
{/* Tabs */}
<div style={{ marginBottom: '30px', display: 'flex', gap: '10px', borderBottom: '2px solid #eee' }}>
<button
onClick={() => setActiveTab('az-live')}
style={{
padding: '12px 24px',
background: activeTab === 'az-live' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'az-live' ? '3px solid #10b981' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'az-live' ? '600' : '400',
color: activeTab === 'az-live' ? '#10b981' : '#666',
marginBottom: '-2px'
}}
>
AZ Live {azActiveJobs.totalActive > 0 && <span style={{ marginLeft: '8px', padding: '2px 8px', background: '#10b981', color: 'white', borderRadius: '10px', fontSize: '12px' }}>{azActiveJobs.totalActive}</span>}
</button>
<button
onClick={() => setActiveTab('jobs')}
style={{
@@ -113,7 +148,329 @@ export function ScraperMonitor() {
</button>
</div>
{activeTab === 'jobs' ? (
{activeTab === 'az-live' && (
<>
{/* AZ Summary Stats */}
{azSummary && (
<div style={{ marginBottom: '30px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '15px' }}>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Running Jobs</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: azActiveJobs.totalActive > 0 ? '#10b981' : '#666' }}>
{azActiveJobs.totalActive}
</div>
</div>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Successful (24h)</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#10b981' }}>
{(azSummary.successful_jobs_24h || 0) + (azSummary.successful_crawls_24h || 0)}
</div>
</div>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Failed (24h)</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: (azSummary.failed_jobs_24h || 0) + (azSummary.failed_crawls_24h || 0) > 0 ? '#ef4444' : '#666' }}>
{(azSummary.failed_jobs_24h || 0) + (azSummary.failed_crawls_24h || 0)}
</div>
</div>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Products (24h)</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#8b5cf6' }}>
{azSummary.products_found_24h || 0}
</div>
</div>
<div style={{ background: 'white', padding: '20px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<div style={{ fontSize: '14px', color: '#999', marginBottom: '8px' }}>Snapshots (24h)</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#06b6d4' }}>
{azSummary.snapshots_created_24h || 0}
</div>
</div>
</div>
</div>
)}
{/* Active Jobs Section */}
<div style={{ marginBottom: '30px' }}>
<h2 style={{ fontSize: '24px', marginBottom: '20px', display: 'flex', alignItems: 'center', gap: '10px' }}>
Active Jobs
{azActiveJobs.totalActive > 0 && (
<span style={{ padding: '4px 12px', background: '#d1fae5', color: '#065f46', borderRadius: '12px', fontSize: '14px', fontWeight: '600' }}>
{azActiveJobs.totalActive} running
</span>
)}
</h2>
{azActiveJobs.totalActive === 0 ? (
<div style={{
background: 'white',
padding: '60px 40px',
borderRadius: '8px',
textAlign: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<div style={{ fontSize: '48px', marginBottom: '20px' }}>😴</div>
<div style={{ fontSize: '18px', color: '#666' }}>No jobs currently running</div>
</div>
) : (
<div style={{ display: 'grid', gap: '15px' }}>
{/* Scheduled Jobs */}
{azActiveJobs.scheduledJobs.map((job: any) => (
<div key={`sched-${job.id}`} style={{
background: 'white',
padding: '20px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
borderLeft: '4px solid #10b981'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
{job.job_name}
</div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '12px' }}>
{job.job_description || 'Scheduled job'}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '12px' }}>
<div>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Processed</div>
<div style={{ fontSize: '16px', fontWeight: '600' }}>{job.items_processed || 0}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Succeeded</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#10b981' }}>{job.items_succeeded || 0}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Failed</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: job.items_failed > 0 ? '#ef4444' : '#666' }}>{job.items_failed || 0}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Duration</div>
<div style={{ fontSize: '16px', fontWeight: '600' }}>
{Math.floor((job.duration_seconds || 0) / 60)}m {Math.floor((job.duration_seconds || 0) % 60)}s
</div>
</div>
</div>
</div>
<div style={{
padding: '6px 12px',
borderRadius: '4px',
fontSize: '13px',
fontWeight: '600',
background: '#d1fae5',
color: '#065f46'
}}>
RUNNING
</div>
</div>
</div>
))}
{/* Individual Crawl Jobs */}
{azActiveJobs.crawlJobs.map((job: any) => (
<div key={`crawl-${job.id}`} style={{
background: 'white',
padding: '20px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
borderLeft: '4px solid #3b82f6'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '18px', fontWeight: '600', marginBottom: '8px' }}>
{job.dispensary_name || 'Unknown Store'}
</div>
<div style={{ fontSize: '14px', color: '#666', marginBottom: '12px' }}>
{job.city} | {job.job_type || 'crawl'}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '12px' }}>
<div>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Products Found</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#8b5cf6' }}>{job.products_found || 0}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Snapshots</div>
<div style={{ fontSize: '16px', fontWeight: '600', color: '#06b6d4' }}>{job.snapshots_created || 0}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>Duration</div>
<div style={{ fontSize: '16px', fontWeight: '600' }}>
{Math.floor((job.duration_seconds || 0) / 60)}m {Math.floor((job.duration_seconds || 0) % 60)}s
</div>
</div>
</div>
</div>
<div style={{
padding: '6px 12px',
borderRadius: '4px',
fontSize: '13px',
fontWeight: '600',
background: '#dbeafe',
color: '#1e40af'
}}>
CRAWLING
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Next Scheduled Runs */}
{azSummary?.nextRuns && azSummary.nextRuns.length > 0 && (
<div style={{ marginBottom: '30px' }}>
<h2 style={{ fontSize: '24px', marginBottom: '20px' }}>Next Scheduled Runs</h2>
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Job</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Next Run</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Last Status</th>
</tr>
</thead>
<tbody>
{azSummary.nextRuns.map((run: any) => (
<tr key={run.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600' }}>{run.job_name}</div>
<div style={{ fontSize: '13px', color: '#666' }}>{run.description}</div>
</td>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600', color: '#2563eb' }}>
{run.next_run_at ? new Date(run.next_run_at).toLocaleString() : '-'}
</div>
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
background: run.last_status === 'success' ? '#d1fae5' : run.last_status === 'error' ? '#fee2e2' : '#fef3c7',
color: run.last_status === 'success' ? '#065f46' : run.last_status === 'error' ? '#991b1b' : '#92400e'
}}>
{run.last_status || 'never'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Recent Errors */}
{azErrors.length > 0 && (
<div style={{ marginBottom: '30px' }}>
<h2 style={{ fontSize: '24px', marginBottom: '20px', color: '#ef4444' }}>Recent Errors (24h)</h2>
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
{azErrors.map((error: any, i: number) => (
<div key={i} style={{ padding: '15px', borderBottom: i < azErrors.length - 1 ? '1px solid #eee' : 'none' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '8px' }}>
<div style={{ fontWeight: '600' }}>{error.job_name || error.dispensary_name}</div>
<span style={{
padding: '2px 8px',
borderRadius: '4px',
fontSize: '11px',
fontWeight: '600',
background: '#fee2e2',
color: '#991b1b'
}}>
{error.status}
</span>
</div>
{error.error_message && (
<div style={{ fontSize: '13px', color: '#991b1b', background: '#fef2f2', padding: '8px', borderRadius: '4px' }}>
{error.error_message}
</div>
)}
<div style={{ fontSize: '12px', color: '#999', marginTop: '8px' }}>
{error.started_at ? new Date(error.started_at).toLocaleString() : '-'}
</div>
</div>
))}
</div>
</div>
)}
{/* Recent Jobs */}
<div>
<h2 style={{ fontSize: '24px', marginBottom: '20px' }}>Recent Job Runs</h2>
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Job</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Status</th>
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Processed</th>
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Duration</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Completed</th>
</tr>
</thead>
<tbody>
{azRecentJobs.jobLogs.slice(0, 20).map((job: any) => (
<tr key={`log-${job.id}`} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600' }}>{job.job_name}</div>
<div style={{ fontSize: '12px', color: '#999' }}>Log #{job.id}</div>
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
background:
job.status === 'success' ? '#d1fae5' :
job.status === 'running' ? '#dbeafe' :
job.status === 'error' ? '#fee2e2' :
'#fef3c7',
color:
job.status === 'success' ? '#065f46' :
job.status === 'running' ? '#1e40af' :
job.status === 'error' ? '#991b1b' :
'#92400e'
}}>
{job.status}
</span>
</td>
<td style={{ padding: '15px', textAlign: 'right' }}>
<span style={{ color: '#10b981' }}>{job.items_succeeded || 0}</span>
{' / '}
<span>{job.items_processed || 0}</span>
</td>
<td style={{ padding: '15px', textAlign: 'right' }}>
{job.duration_ms ? `${Math.floor(job.duration_ms / 60000)}m ${Math.floor((job.duration_ms % 60000) / 1000)}s` : '-'}
</td>
<td style={{ padding: '15px', color: '#666' }}>
{job.completed_at ? new Date(job.completed_at).toLocaleString() : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
{activeTab === 'jobs' && (
<>
{/* Job Stats */}
{jobStats && (
@@ -343,7 +700,9 @@ export function ScraperMonitor() {
</div>
</div>
</>
) : (
)}
{activeTab === 'scrapers' && (
<>
{/* Active Scrapers */}
<div style={{ marginBottom: '30px' }}>

View File

@@ -0,0 +1,470 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import {
Building2,
Package,
Tag,
TrendingUp,
AlertCircle,
CheckCircle,
XCircle,
Clock,
RefreshCw,
ChevronRight,
BarChart3,
Layers,
} from 'lucide-react';
interface DashboardStats {
dispensaryCount: number;
productCount: number;
snapshotCount24h: number;
lastCrawlTime: string | null;
failedJobCount: number;
brandCount: number;
categoryCount: number;
}
interface Store {
id: number;
name: string;
dba_name?: string;
city: string;
state: string;
platform_dispensary_id?: string;
last_crawl_at?: string;
product_count?: number;
}
interface Brand {
brand_name: string;
product_count: number;
dispensary_count: number;
product_types?: string[];
}
interface Category {
type: string;
subcategory: string | null;
product_count: number;
dispensary_count: number;
brand_count: number;
avg_thc?: number | null;
}
export function WholesaleAnalytics() {
const navigate = useNavigate();
const [dashboard, setDashboard] = useState<DashboardStats | null>(null);
const [stores, setStores] = useState<Store[]>([]);
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'overview' | 'stores' | 'brands' | 'categories'>('overview');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [dashboardData, storesData, brandsData, categoriesData] = await Promise.all([
api.getDutchieAZDashboard(),
api.getDutchieAZStores({ limit: 200 }),
api.getDutchieAZBrands ? api.getDutchieAZBrands({ limit: 100 }) : Promise.resolve({ brands: [] }),
api.getDutchieAZCategories ? api.getDutchieAZCategories() : Promise.resolve({ categories: [] }),
]);
setDashboard(dashboardData);
setStores(storesData.stores || []);
setBrands(brandsData.brands || []);
setCategories(categoriesData.categories || []);
} catch (error) {
console.error('Failed to load analytics data:', error);
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffHours < 1) return 'Just now';
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
};
if (loading) {
return (
<Layout>
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
<p className="mt-2 text-sm text-gray-600">Loading analytics...</p>
</div>
</Layout>
);
}
return (
<Layout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Wholesale & Inventory Analytics</h1>
<p className="text-sm text-gray-600 mt-1">
Arizona Dutchie dispensaries data overview
</p>
</div>
<button
onClick={loadData}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
{/* Top Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
<StatCard
title="Dispensaries"
value={dashboard?.dispensaryCount || 0}
icon={<Building2 className="w-5 h-5 text-blue-600" />}
color="blue"
/>
<StatCard
title="Total Products"
value={dashboard?.productCount || 0}
icon={<Package className="w-5 h-5 text-green-600" />}
color="green"
/>
<StatCard
title="Brands"
value={dashboard?.brandCount || 0}
icon={<Tag className="w-5 h-5 text-purple-600" />}
color="purple"
/>
<StatCard
title="Categories"
value={dashboard?.categoryCount || 0}
icon={<Layers className="w-5 h-5 text-orange-600" />}
color="orange"
/>
<StatCard
title="Snapshots (24h)"
value={dashboard?.snapshotCount24h || 0}
icon={<TrendingUp className="w-5 h-5 text-cyan-600" />}
color="cyan"
/>
<StatCard
title="Failed Jobs (24h)"
value={dashboard?.failedJobCount || 0}
icon={<AlertCircle className="w-5 h-5 text-red-600" />}
color="red"
/>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2 text-gray-600 mb-1">
<Clock className="w-4 h-4" />
<span className="text-xs">Last Crawl</span>
</div>
<p className="text-sm font-semibold text-gray-900">
{formatDate(dashboard?.lastCrawlTime || null)}
</p>
</div>
</div>
{/* Navigation Tabs */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="border-b border-gray-200">
<div className="flex gap-4 px-6">
<TabButton
active={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
icon={<BarChart3 className="w-4 h-4" />}
label="Overview"
/>
<TabButton
active={activeTab === 'stores'}
onClick={() => setActiveTab('stores')}
icon={<Building2 className="w-4 h-4" />}
label={`Stores (${stores.length})`}
/>
<TabButton
active={activeTab === 'brands'}
onClick={() => setActiveTab('brands')}
icon={<Tag className="w-4 h-4" />}
label={`Brands (${brands.length})`}
/>
<TabButton
active={activeTab === 'categories'}
onClick={() => setActiveTab('categories')}
icon={<Layers className="w-4 h-4" />}
label={`Categories (${categories.length})`}
/>
</div>
</div>
<div className="p-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Top Stores */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Stores by Products</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{stores.slice(0, 6).map((store) => (
<button
key={store.id}
onClick={() => navigate(`/az/stores/${store.id}`)}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors text-left"
>
<div>
<p className="font-medium text-gray-900">
{store.dba_name || store.name}
</p>
<p className="text-sm text-gray-600">{store.city}, {store.state}</p>
<p className="text-xs text-gray-500 mt-1">
Last crawl: {formatDate(store.last_crawl_at || null)}
</p>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
))}
</div>
</div>
{/* Top Brands */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Brands</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{brands.slice(0, 12).map((brand) => (
<div
key={brand.brand_name}
className="p-4 bg-gray-50 rounded-lg text-center"
>
<p className="font-medium text-gray-900 text-sm line-clamp-2">
{brand.brand_name}
</p>
<p className="text-lg font-bold text-purple-600 mt-1">
{brand.product_count}
</p>
<p className="text-xs text-gray-500">products</p>
</div>
))}
</div>
</div>
{/* Top Categories */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Product Categories</h3>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{categories.slice(0, 12).map((cat, idx) => (
<div
key={idx}
className="p-4 bg-gray-50 rounded-lg text-center"
>
<p className="font-medium text-gray-900">{cat.type}</p>
{cat.subcategory && (
<p className="text-xs text-gray-600">{cat.subcategory}</p>
)}
<p className="text-lg font-bold text-orange-600 mt-1">
{cat.product_count}
</p>
<p className="text-xs text-gray-500">products</p>
</div>
))}
</div>
</div>
</div>
)}
{/* Stores Tab */}
{activeTab === 'stores' && (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="table table-sm w-full">
<thead>
<tr>
<th>Store Name</th>
<th>City</th>
<th className="text-center">Platform ID</th>
<th className="text-center">Last Crawl</th>
<th></th>
</tr>
</thead>
<tbody>
{stores.map((store) => (
<tr key={store.id} className="hover">
<td className="font-medium">
{store.dba_name || store.name}
</td>
<td>{store.city}, {store.state}</td>
<td className="text-center">
{store.platform_dispensary_id ? (
<span className="badge badge-success badge-sm">Resolved</span>
) : (
<span className="badge badge-warning badge-sm">Pending</span>
)}
</td>
<td className="text-center text-sm text-gray-600">
{formatDate(store.last_crawl_at || null)}
</td>
<td>
<button
onClick={() => navigate(`/az/stores/${store.id}`)}
className="btn btn-xs btn-ghost"
>
View
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Brands Tab */}
{activeTab === 'brands' && (
<div className="space-y-4">
{brands.length === 0 ? (
<p className="text-center py-8 text-gray-500">
No brands found. Run a crawl to populate brand data.
</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{brands.map((brand) => (
<div
key={brand.brand_name}
className="border border-gray-200 rounded-lg p-4 text-center hover:border-purple-300 hover:shadow-md transition-all"
>
<p className="font-medium text-gray-900 text-sm line-clamp-2 h-10">
{brand.brand_name}
</p>
<div className="mt-2 space-y-1">
<div className="flex justify-between text-xs">
<span className="text-gray-500">Products:</span>
<span className="font-semibold">{brand.product_count}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-gray-500">Stores:</span>
<span className="font-semibold">{brand.dispensary_count}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Categories Tab */}
{activeTab === 'categories' && (
<div className="space-y-4">
{categories.length === 0 ? (
<p className="text-center py-8 text-gray-500">
No categories found. Run a crawl to populate category data.
</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{categories.map((cat, idx) => (
<div
key={idx}
className="border border-gray-200 rounded-lg p-4 hover:border-orange-300 hover:shadow-md transition-all"
>
<p className="font-medium text-gray-900">{cat.type}</p>
{cat.subcategory && (
<p className="text-sm text-gray-600">{cat.subcategory}</p>
)}
<div className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div className="bg-gray-50 rounded p-2 text-center">
<p className="font-bold text-lg text-orange-600">{cat.product_count}</p>
<p className="text-gray-500">products</p>
</div>
<div className="bg-gray-50 rounded p-2 text-center">
<p className="font-bold text-lg text-blue-600">{cat.brand_count}</p>
<p className="text-gray-500">brands</p>
</div>
</div>
{cat.avg_thc != null && (
<p className="text-xs text-gray-500 mt-2 text-center">
Avg THC: {cat.avg_thc.toFixed(1)}%
</p>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</Layout>
);
}
interface StatCardProps {
title: string;
value: number;
icon: React.ReactNode;
color: string;
}
function StatCard({ title, value, icon, color }: StatCardProps) {
const colorClasses: Record<string, string> = {
blue: 'bg-blue-50',
green: 'bg-green-50',
purple: 'bg-purple-50',
orange: 'bg-orange-50',
cyan: 'bg-cyan-50',
red: 'bg-red-50',
};
return (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className={`p-2 ${colorClasses[color] || 'bg-gray-50'} rounded-lg`}>
{icon}
</div>
<div>
<p className="text-xs text-gray-600">{title}</p>
<p className="text-xl font-bold text-gray-900">{value.toLocaleString()}</p>
</div>
</div>
</div>
);
}
interface TabButtonProps {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}
function TabButton({ active, onClick, icon, label }: TabButtonProps) {
return (
<button
onClick={onClick}
className={`flex items-center gap-2 py-4 px-2 text-sm font-medium border-b-2 transition-colors ${
active
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
{icon}
{label}
</button>
);
}