Files
cannaiq/frontend/src/pages/ScraperSchedule.tsx
Kelly a0f8d3911c feat: Add Findagram and FindADispo consumer frontends
- Add findagram.co React frontend with product search, brands, categories
- Add findadispo.com React frontend with dispensary locator
- Wire findagram to backend /api/az/* endpoints
- Update category/brand links to route to /products with filters
- Add k8s manifests for both frontends
- Add multi-domain user support migrations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:10:15 -07:00

981 lines
43 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
interface GlobalSchedule {
id: number;
schedule_type: string;
enabled: boolean;
interval_hours?: number;
run_time?: string;
description?: string;
}
// Dispensary-centric schedule data (from dispensary_crawl_status view)
interface DispensarySchedule {
dispensary_id: number;
dispensary_name: string;
city: string | null;
state: string | null;
dispensary_slug: string | null;
slug: string | null;
website: string | null;
menu_url: string | null;
menu_type: string | null;
platform_dispensary_id: string | null;
product_provider: string | null;
provider_type: string | null;
product_confidence: number | null;
product_crawler_mode: string | null;
last_product_scan_at: string | null;
is_active: boolean;
schedule_active: boolean;
interval_minutes: number | null;
priority: number;
last_run_at: string | null;
next_run_at: string | null;
schedule_last_status: string | null;
last_status: string | null;
last_summary: string | null;
schedule_last_error: string | null;
last_error: string | null;
consecutive_failures: number | null;
total_runs: number | null;
successful_runs: number | null;
latest_job_id: number | null;
latest_job_type: string | null;
latest_job_status: string | null;
latest_job_started: string | null;
latest_products_found: number | null;
// Computed from view
can_crawl: boolean;
schedule_status_reason: string | null;
}
interface CrawlJob {
id: number;
dispensary_id: number;
dispensary_name: string;
job_type: string;
trigger_type: string;
status: string;
priority: number;
scheduled_at: string;
started_at: string | null;
completed_at: string | null;
products_found: number | null;
products_new: number | null;
products_updated: number | null;
error_message: string | null;
}
export function ScraperSchedule() {
const [globalSchedules, setGlobalSchedules] = useState<GlobalSchedule[]>([]);
const [dispensarySchedules, setDispensarySchedules] = useState<DispensarySchedule[]>([]);
const [jobs, setJobs] = useState<CrawlJob[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [activeTab, setActiveTab] = useState<'dispensaries' | 'jobs' | 'global'>('dispensaries');
const [triggeringDispensary, setTriggeringDispensary] = useState<number | null>(null);
const [resolvingId, setResolvingId] = useState<number | null>(null);
const [refreshingDetection, setRefreshingDetection] = useState<number | null>(null);
const [togglingSchedule, setTogglingSchedule] = useState<number | null>(null);
const [filterDutchieOnly, setFilterDutchieOnly] = useState(false);
const [stateFilter, setStateFilter] = useState<'all' | 'AZ'>('all');
const [searchTerm, setSearchTerm] = useState('');
const [searchInput, setSearchInput] = useState(''); // For debouncing
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setSearchTerm(searchInput);
}, 300);
return () => clearTimeout(timer);
}, [searchInput]);
useEffect(() => {
loadData();
if (autoRefresh) {
const interval = setInterval(loadData, 5000);
return () => clearInterval(interval);
}
}, [autoRefresh, stateFilter, searchTerm]);
const loadData = async () => {
try {
// Build filters for dispensary schedules
const filters: { state?: string; search?: string } = {};
if (stateFilter === 'AZ') {
filters.state = 'AZ';
}
if (searchTerm.trim()) {
filters.search = searchTerm.trim();
}
const [globalData, dispensaryData, jobsData] = await Promise.all([
api.getGlobalSchedule(),
api.getDispensarySchedules(Object.keys(filters).length > 0 ? filters : undefined),
api.getDispensaryCrawlJobs(100)
]);
setGlobalSchedules(globalData.schedules || []);
setDispensarySchedules(dispensaryData.dispensaries || []);
setJobs(jobsData.jobs || []);
} catch (error) {
console.error('Failed to load schedule data:', error);
} finally {
setLoading(false);
}
};
const handleTriggerCrawl = async (dispensaryId: number) => {
setTriggeringDispensary(dispensaryId);
try {
await api.triggerDispensaryCrawl(dispensaryId);
await loadData();
} catch (error) {
console.error('Failed to trigger crawl:', error);
} finally {
setTriggeringDispensary(null);
}
};
const handleTriggerAll = async () => {
if (!confirm('This will create crawl jobs for ALL active stores. Continue?')) return;
try {
const result = await api.triggerAllCrawls();
alert(`Created ${result.jobs_created} crawl jobs`);
await loadData();
} catch (error) {
console.error('Failed to trigger all crawls:', error);
}
};
const handleCancelJob = async (jobId: number) => {
try {
await api.cancelCrawlJob(jobId);
await loadData();
} catch (error) {
console.error('Failed to cancel job:', error);
}
};
const handleResolvePlatformId = async (dispensaryId: number) => {
setResolvingId(dispensaryId);
try {
const result = await api.resolvePlatformId(dispensaryId);
if (result.success) {
alert(result.message);
} else {
alert(`Failed: ${result.error || result.message}`);
}
await loadData();
} catch (error: any) {
console.error('Failed to resolve platform ID:', error);
alert(`Error: ${error.message}`);
} finally {
setResolvingId(null);
}
};
const handleRefreshDetection = async (dispensaryId: number) => {
setRefreshingDetection(dispensaryId);
try {
const result = await api.refreshDetection(dispensaryId);
alert(`Detected: ${result.menu_type}${result.platform_dispensary_id ? `, Platform ID: ${result.platform_dispensary_id}` : ''}`);
await loadData();
} catch (error: any) {
console.error('Failed to refresh detection:', error);
alert(`Error: ${error.message}`);
} finally {
setRefreshingDetection(null);
}
};
const handleToggleSchedule = async (dispensaryId: number, currentActive: boolean) => {
setTogglingSchedule(dispensaryId);
try {
await api.toggleDispensarySchedule(dispensaryId, !currentActive);
await loadData();
} catch (error: any) {
console.error('Failed to toggle schedule:', error);
alert(`Error: ${error.message}`);
} finally {
setTogglingSchedule(null);
}
};
const handleDeleteSchedule = async (dispensaryId: number) => {
if (!confirm('Are you sure you want to delete this schedule?')) return;
try {
await api.deleteDispensarySchedule(dispensaryId);
await loadData();
} catch (error: any) {
console.error('Failed to delete schedule:', error);
alert(`Error: ${error.message}`);
}
};
const handleUpdateGlobalSchedule = async (type: string, data: any) => {
try {
await api.updateGlobalSchedule(type, data);
await loadData();
} catch (error) {
console.error('Failed to update global schedule:', error);
}
};
const formatTimeAgo = (dateString: string | null) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};
const formatTimeUntil = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
if (diffMs < 0) return 'Overdue';
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
if (diffMins < 60) return `${diffMins}m`;
return `${diffHours}h ${diffMins % 60}m`;
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
case 'success': return { bg: '#d1fae5', color: '#065f46' };
case 'running': return { bg: '#dbeafe', color: '#1e40af' };
case 'failed':
case 'error': return { bg: '#fee2e2', color: '#991b1b' };
case 'cancelled': return { bg: '#f3f4f6', color: '#374151' };
case 'pending': return { bg: '#fef3c7', color: '#92400e' };
case 'sandbox_only': return { bg: '#e0e7ff', color: '#3730a3' };
case 'detection_only': return { bg: '#fce7f3', color: '#9d174d' };
default: return { bg: '#f3f4f6', color: '#374151' };
}
};
const getProviderBadge = (provider: string | null, mode: string | null) => {
if (!provider) return null;
const isProduction = mode === 'production';
return {
label: provider,
bg: isProduction ? '#d1fae5' : '#fef3c7',
color: isProduction ? '#065f46' : '#92400e',
suffix: isProduction ? '' : ' (sandbox)'
};
};
const globalIntervalSchedule = globalSchedules.find(s => s.schedule_type === 'global_interval');
const dailySpecialSchedule = globalSchedules.find(s => s.schedule_type === 'daily_special');
return (
<Layout>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '30px' }}>
<h1 style={{ fontSize: '32px', margin: 0 }}>Crawler Schedule</h1>
<div style={{ display: 'flex', gap: '15px', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>Auto-refresh (5s)</span>
</label>
<button
onClick={handleTriggerAll}
style={{
padding: '10px 20px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: '600'
}}
>
Crawl All Stores
</button>
</div>
</div>
{/* Tabs */}
<div style={{ marginBottom: '30px', display: 'flex', gap: '10px', borderBottom: '2px solid #eee' }}>
<button
onClick={() => setActiveTab('dispensaries')}
style={{
padding: '12px 24px',
background: activeTab === 'dispensaries' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'dispensaries' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'dispensaries' ? '600' : '400',
color: activeTab === 'dispensaries' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Dispensary Schedules ({dispensarySchedules.length})
</button>
<button
onClick={() => setActiveTab('jobs')}
style={{
padding: '12px 24px',
background: activeTab === 'jobs' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'jobs' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'jobs' ? '600' : '400',
color: activeTab === 'jobs' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Job Queue ({jobs.filter(j => j.status === 'pending' || j.status === 'running').length})
</button>
<button
onClick={() => setActiveTab('global')}
style={{
padding: '12px 24px',
background: activeTab === 'global' ? 'white' : 'transparent',
border: 'none',
borderBottom: activeTab === 'global' ? '3px solid #2563eb' : '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === 'global' ? '600' : '400',
color: activeTab === 'global' ? '#2563eb' : '#666',
marginBottom: '-2px'
}}
>
Global Settings
</button>
</div>
{activeTab === 'global' && (
<div style={{ display: 'grid', gap: '20px' }}>
{/* Global Interval Schedule */}
<div style={{
background: 'white',
padding: '24px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '20px' }}>
<div>
<h2 style={{ fontSize: '20px', margin: 0, marginBottom: '8px' }}>Interval Crawl Schedule</h2>
<p style={{ color: '#666', margin: 0 }}>Crawl all stores periodically</p>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<span style={{ color: '#666' }}>Enabled</span>
<input
type="checkbox"
checked={globalIntervalSchedule?.enabled ?? true}
onChange={(e) => handleUpdateGlobalSchedule('global_interval', { enabled: e.target.checked })}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span>Crawl every</span>
<select
value={globalIntervalSchedule?.interval_hours ?? 4}
onChange={(e) => handleUpdateGlobalSchedule('global_interval', { interval_hours: parseInt(e.target.value) })}
style={{
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '16px'
}}
>
<option value={1}>1 hour</option>
<option value={2}>2 hours</option>
<option value={4}>4 hours</option>
<option value={6}>6 hours</option>
<option value={8}>8 hours</option>
<option value={12}>12 hours</option>
<option value={24}>24 hours</option>
</select>
</label>
</div>
</div>
{/* Daily Special Schedule */}
<div style={{
background: 'white',
padding: '24px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '20px' }}>
<div>
<h2 style={{ fontSize: '20px', margin: 0, marginBottom: '8px' }}>Daily Special Crawl</h2>
<p style={{ color: '#666', margin: 0 }}>Crawl stores at local midnight to capture daily specials</p>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<span style={{ color: '#666' }}>Enabled</span>
<input
type="checkbox"
checked={dailySpecialSchedule?.enabled ?? true}
onChange={(e) => handleUpdateGlobalSchedule('daily_special', { enabled: e.target.checked })}
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
/>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span>Run at</span>
<input
type="time"
value={dailySpecialSchedule?.run_time?.slice(0, 5) ?? '00:01'}
onChange={(e) => handleUpdateGlobalSchedule('daily_special', { run_time: e.target.value })}
style={{
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #ddd',
fontSize: '16px'
}}
/>
<span style={{ color: '#666' }}>(store local time)</span>
</label>
</div>
</div>
</div>
)}
{activeTab === 'dispensaries' && (
<div>
{/* Filter Bar */}
<div style={{ marginBottom: '15px', display: 'flex', gap: '20px', alignItems: 'center', flexWrap: 'wrap' }}>
{/* State Filter Toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: '500', color: '#374151' }}>State:</span>
<div style={{ display: 'flex', borderRadius: '6px', overflow: 'hidden', border: '1px solid #d1d5db' }}>
<button
onClick={() => setStateFilter('all')}
style={{
padding: '6px 14px',
background: stateFilter === 'all' ? '#2563eb' : 'white',
color: stateFilter === 'all' ? 'white' : '#374151',
border: 'none',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
All
</button>
<button
onClick={() => setStateFilter('AZ')}
style={{
padding: '6px 14px',
background: stateFilter === 'AZ' ? '#2563eb' : 'white',
color: stateFilter === 'AZ' ? 'white' : '#374151',
border: 'none',
borderLeft: '1px solid #d1d5db',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
AZ Only
</button>
</div>
</div>
{/* Search Box */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: '500', color: '#374151' }}>Search:</span>
<input
type="text"
placeholder="Store name or slug..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: '1px solid #d1d5db',
fontSize: '14px',
width: '200px'
}}
/>
{searchInput && (
<button
onClick={() => { setSearchInput(''); setSearchTerm(''); }}
style={{
padding: '4px 8px',
background: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Clear
</button>
)}
</div>
{/* Dutchie Only Checkbox */}
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={filterDutchieOnly}
onChange={(e) => setFilterDutchieOnly(e.target.checked)}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<span>CannaIQ only</span>
</label>
{/* Results Count */}
<span style={{ color: '#666', fontSize: '14px', marginLeft: 'auto' }}>
Showing {(filterDutchieOnly
? dispensarySchedules.filter(d => d.menu_type === 'dutchie')
: dispensarySchedules
).length} dispensaries
</span>
</div>
<div style={{
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
overflow: 'auto'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '1200px' }}>
<thead>
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600' }}>Dispensary</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600' }}>Menu Type</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600' }}>Platform ID</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600' }}>Status</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600' }}>Last Run</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600' }}>Next Run</th>
<th style={{ padding: '12px', textAlign: 'left', fontWeight: '600' }}>Last Result</th>
<th style={{ padding: '12px', textAlign: 'center', fontWeight: '600', minWidth: '220px' }}>Actions</th>
</tr>
</thead>
<tbody>
{(filterDutchieOnly
? dispensarySchedules.filter(d => d.menu_type === 'dutchie')
: dispensarySchedules
).map((disp) => (
<tr key={disp.dispensary_id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{disp.state && disp.city && (disp.dispensary_slug || disp.slug) ? (
<Link
to={`/dispensaries/${disp.state}/${disp.city.toLowerCase().replace(/\s+/g, '-')}/${disp.dispensary_slug || disp.slug}`}
style={{
fontWeight: '600',
color: '#2563eb',
textDecoration: 'none'
}}
>
{disp.dispensary_name}
</Link>
) : (
<span style={{ fontWeight: '600' }}>{disp.dispensary_name}</span>
)}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{disp.city ? `${disp.city}, ${disp.state}` : disp.state}
</div>
</td>
{/* Menu Type Column */}
<td style={{ padding: '12px', textAlign: 'center' }}>
{disp.menu_type ? (
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '600',
background: disp.menu_type === 'dutchie' ? '#d1fae5' : '#e0e7ff',
color: disp.menu_type === 'dutchie' ? '#065f46' : '#3730a3'
}}>
{disp.menu_type}
</span>
) : (
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '600',
background: '#f3f4f6',
color: '#666'
}}>
unknown
</span>
)}
</td>
{/* Platform ID Column */}
<td style={{ padding: '12px', textAlign: 'center' }}>
{disp.platform_dispensary_id ? (
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'monospace',
background: '#d1fae5',
color: '#065f46'
}} title={disp.platform_dispensary_id}>
{disp.platform_dispensary_id.length > 12
? `${disp.platform_dispensary_id.slice(0, 6)}...${disp.platform_dispensary_id.slice(-4)}`
: disp.platform_dispensary_id}
</span>
) : (
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '10px',
background: '#fee2e2',
color: '#991b1b'
}}>
missing
</span>
)}
</td>
{/* Status Column - Shows can_crawl and reason */}
<td style={{ padding: '12px', textAlign: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '600',
background: disp.can_crawl ? '#d1fae5' : (disp.is_active !== false ? '#fef3c7' : '#fee2e2'),
color: disp.can_crawl ? '#065f46' : (disp.is_active !== false ? '#92400e' : '#991b1b')
}}>
{disp.can_crawl ? 'Ready' : (disp.is_active !== false ? 'Not Ready' : 'Disabled')}
</span>
{disp.schedule_status_reason && disp.schedule_status_reason !== 'ready' && (
<span style={{ fontSize: '10px', color: '#666', maxWidth: '100px', textAlign: 'center' }}>
{disp.schedule_status_reason}
</span>
)}
{disp.interval_minutes && (
<span style={{ fontSize: '10px', color: '#999' }}>
Every {Math.round(disp.interval_minutes / 60)}h
</span>
)}
</div>
</td>
<td style={{ padding: '15px' }}>
<div>{formatTimeAgo(disp.last_run_at)}</div>
{disp.last_run_at && (
<div style={{ fontSize: '12px', color: '#999' }}>
{new Date(disp.last_run_at).toLocaleString()}
</div>
)}
</td>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600', color: '#2563eb' }}>
{disp.next_run_at ? formatTimeUntil(disp.next_run_at) : 'Not scheduled'}
</div>
</td>
<td style={{ padding: '15px' }}>
{disp.last_status || disp.latest_job_status ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
...getStatusColor(disp.last_status || disp.latest_job_status || 'pending')
}}>
{disp.last_status || disp.latest_job_status}
</span>
{disp.last_error && (
<button
onClick={() => alert(disp.last_error)}
style={{
padding: '2px 6px',
background: '#fee2e2',
color: '#991b1b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '10px'
}}
>
Error
</button>
)}
</div>
{disp.last_summary ? (
<div style={{ fontSize: '12px', color: '#666', maxWidth: '250px' }}>
{disp.last_summary}
</div>
) : disp.latest_products_found !== null ? (
<div style={{ fontSize: '12px', color: '#666' }}>
{disp.latest_products_found} products
</div>
) : null}
</div>
) : (
<span style={{ color: '#999', fontSize: '13px' }}>No runs yet</span>
)}
</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<div style={{ display: 'flex', gap: '6px', justifyContent: 'center', flexWrap: 'wrap' }}>
{/* Refresh Detection - always available */}
<button
onClick={() => handleRefreshDetection(disp.dispensary_id)}
disabled={refreshingDetection === disp.dispensary_id}
style={{
padding: '4px 8px',
background: refreshingDetection === disp.dispensary_id ? '#94a3b8' : '#f3f4f6',
color: '#374151',
border: '1px solid #d1d5db',
borderRadius: '4px',
cursor: refreshingDetection === disp.dispensary_id ? 'wait' : 'pointer',
fontSize: '11px'
}}
title="Re-detect menu type and resolve platform ID"
>
{refreshingDetection === disp.dispensary_id ? '...' : 'Refresh'}
</button>
{/* Resolve ID - only if dutchie and missing platform ID */}
{disp.menu_type === 'dutchie' && !disp.platform_dispensary_id && (
<button
onClick={() => handleResolvePlatformId(disp.dispensary_id)}
disabled={resolvingId === disp.dispensary_id}
style={{
padding: '4px 8px',
background: resolvingId === disp.dispensary_id ? '#94a3b8' : '#fef3c7',
color: '#92400e',
border: '1px solid #fcd34d',
borderRadius: '4px',
cursor: resolvingId === disp.dispensary_id ? 'wait' : 'pointer',
fontSize: '11px'
}}
title="Resolve platform dispensary ID via GraphQL"
>
{resolvingId === disp.dispensary_id ? '...' : 'Resolve ID'}
</button>
)}
{/* Run Now - only if can_crawl */}
<button
onClick={() => handleTriggerCrawl(disp.dispensary_id)}
disabled={triggeringDispensary === disp.dispensary_id || !disp.can_crawl}
style={{
padding: '4px 8px',
background: triggeringDispensary === disp.dispensary_id ? '#94a3b8' :
!disp.can_crawl ? '#e5e7eb' : '#2563eb',
color: !disp.can_crawl ? '#9ca3af' : 'white',
border: 'none',
borderRadius: '4px',
cursor: triggeringDispensary === disp.dispensary_id || !disp.can_crawl ? 'not-allowed' : 'pointer',
fontSize: '11px'
}}
title={disp.can_crawl ? 'Trigger immediate crawl' : `Cannot crawl: ${disp.schedule_status_reason}`}
>
{triggeringDispensary === disp.dispensary_id ? '...' : 'Run'}
</button>
{/* Enable/Disable Schedule Toggle */}
<button
onClick={() => handleToggleSchedule(disp.dispensary_id, disp.is_active)}
disabled={togglingSchedule === disp.dispensary_id}
style={{
padding: '4px 8px',
background: togglingSchedule === disp.dispensary_id ? '#94a3b8' :
disp.is_active ? '#fee2e2' : '#d1fae5',
color: disp.is_active ? '#991b1b' : '#065f46',
border: 'none',
borderRadius: '4px',
cursor: togglingSchedule === disp.dispensary_id ? 'wait' : 'pointer',
fontSize: '11px'
}}
title={disp.is_active ? 'Disable scheduled crawling' : 'Enable scheduled crawling'}
>
{togglingSchedule === disp.dispensary_id ? '...' : (disp.is_active ? 'Disable' : 'Enable')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'jobs' && (
<>
{/* Job Stats */}
<div style={{ marginBottom: '30px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 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' }}>Pending</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#f59e0b' }}>
{jobs.filter(j => j.status === 'pending').length}
</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' }}>Running</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#3b82f6' }}>
{jobs.filter(j => j.status === 'running').length}
</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' }}>Completed</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#10b981' }}>
{jobs.filter(j => j.status === 'completed').length}
</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</div>
<div style={{ fontSize: '32px', fontWeight: '600', color: '#ef4444' }}>
{jobs.filter(j => j.status === 'failed').length}
</div>
</div>
</div>
</div>
{/* Jobs Table */}
<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' }}>Dispensary</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Type</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Trigger</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Status</th>
<th style={{ padding: '15px', textAlign: 'right', fontWeight: '600' }}>Products</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Started</th>
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Completed</th>
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Actions</th>
</tr>
</thead>
<tbody>
{jobs.length === 0 ? (
<tr>
<td colSpan={8} style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
No crawl jobs found
</td>
</tr>
) : (
jobs.map((job) => (
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '15px' }}>
<div style={{ fontWeight: '600' }}>{job.dispensary_name}</div>
<div style={{ fontSize: '12px', color: '#999' }}>Job #{job.id}</div>
</td>
<td style={{ padding: '15px', textAlign: 'center', fontSize: '13px' }}>
{job.job_type}
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<span style={{
padding: '3px 8px',
borderRadius: '4px',
fontSize: '12px',
background: job.trigger_type === 'manual' ? '#e0e7ff' :
job.trigger_type === 'daily_special' ? '#fce7f3' : '#f3f4f6',
color: job.trigger_type === 'manual' ? '#3730a3' :
job.trigger_type === 'daily_special' ? '#9d174d' : '#374151'
}}>
{job.trigger_type}
</span>
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
...getStatusColor(job.status)
}}>
{job.status}
</span>
</td>
<td style={{ padding: '15px', textAlign: 'right' }}>
{job.products_found !== null ? (
<div>
<div style={{ fontWeight: '600' }}>{job.products_found}</div>
{job.products_new !== null && job.products_updated !== null && (
<div style={{ fontSize: '12px', color: '#666' }}>
+{job.products_new} / ~{job.products_updated}
</div>
)}
</div>
) : '-'}
</td>
<td style={{ padding: '15px', fontSize: '13px' }}>
{job.started_at ? new Date(job.started_at).toLocaleString() : '-'}
</td>
<td style={{ padding: '15px', fontSize: '13px' }}>
{job.completed_at ? new Date(job.completed_at).toLocaleString() : '-'}
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
{job.status === 'pending' && (
<button
onClick={() => handleCancelJob(job.id)}
style={{
padding: '4px 10px',
background: '#fee2e2',
color: '#991b1b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
)}
{job.error_message && (
<button
onClick={() => alert(job.error_message)}
style={{
padding: '4px 10px',
background: '#fee2e2',
color: '#991b1b',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
View Error
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
)}
</div>
</Layout>
);
}