- 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>
981 lines
43 KiB
TypeScript
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>
|
|
);
|
|
}
|