Switch scheduler UI to dispensary-based API
- Add migrations 021-023 for dispensary_crawl_schedule tables and views - Add dispensary-orchestrator service and bootstrap-discovery script - Update schedule routes with dispensary endpoints (/api/schedule/dispensaries) - Switch frontend scheduler to use canonical dispensaries table (182 AZDHS entries) - Add dispensary schedule API methods to frontend api.ts - Remove "Unmapped" badge logic - all dispensaries now linked properly - Add proper URL linking to dispensary detail pages (/dispensaries/:state/:city/:slug) - Update Jobs table to show dispensary_name 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -82,8 +82,8 @@ export function Layout({ children }: LayoutProps) {
|
||||
useEffect(() => {
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const response = await api.get('/version');
|
||||
setVersionInfo(response.data);
|
||||
const data = await api.getVersion();
|
||||
setVersionInfo(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version info:', error);
|
||||
}
|
||||
|
||||
@@ -451,6 +451,33 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Dispensary Schedule (new dispensary-centric API)
|
||||
async getDispensarySchedules() {
|
||||
return this.request<{ dispensaries: any[] }>('/api/schedule/dispensaries');
|
||||
}
|
||||
|
||||
async getDispensarySchedule(dispensaryId: number) {
|
||||
return this.request<{ schedule: any }>(`/api/schedule/dispensaries/${dispensaryId}`);
|
||||
}
|
||||
|
||||
async updateDispensarySchedule(dispensaryId: number, data: any) {
|
||||
return this.request<{ schedule: any }>(`/api/schedule/dispensaries/${dispensaryId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getDispensaryCrawlJobs(limit?: number) {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
return this.request<{ jobs: any[] }>(`/api/schedule/dispensary-jobs${params}`);
|
||||
}
|
||||
|
||||
async triggerDispensaryCrawl(dispensaryId: number) {
|
||||
return this.request<{ result: any; message: string; success: boolean }>(`/api/schedule/trigger/dispensary/${dispensaryId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async getCrawlJobs(limit?: number) {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
return this.request<{ jobs: any[] }>(`/api/schedule/jobs${params}`);
|
||||
@@ -484,6 +511,16 @@ class ApiClient {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// Version
|
||||
async getVersion() {
|
||||
return this.request<{
|
||||
build_version: string;
|
||||
git_sha: string;
|
||||
build_time: string;
|
||||
image_tag: string;
|
||||
}>('/api/version');
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_URL);
|
||||
|
||||
@@ -60,9 +60,11 @@ export function Dispensaries() {
|
||||
};
|
||||
|
||||
const filteredDispensaries = dispensaries.filter(disp => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch = !searchTerm ||
|
||||
disp.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(disp.company_name && disp.company_name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
disp.name.toLowerCase().includes(searchLower) ||
|
||||
(disp.company_name && disp.company_name.toLowerCase().includes(searchLower)) ||
|
||||
(disp.dba_name && disp.dba_name.toLowerCase().includes(searchLower));
|
||||
const matchesCity = !filterCity || disp.city === filterCity;
|
||||
return matchesSearch && matchesCity;
|
||||
});
|
||||
|
||||
@@ -12,50 +12,41 @@ interface GlobalSchedule {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface StoreSchedule {
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
store_slug: string;
|
||||
timezone: string;
|
||||
active: boolean;
|
||||
scrape_enabled: boolean;
|
||||
last_scraped_at: string | null;
|
||||
schedule_enabled: boolean;
|
||||
interval_hours: number;
|
||||
daily_special_enabled: boolean;
|
||||
daily_special_time: string;
|
||||
priority: number;
|
||||
next_scheduled_run: string;
|
||||
latest_job_id: number | null;
|
||||
latest_job_status: string | null;
|
||||
latest_job_type: string | null;
|
||||
latest_job_trigger: string | null;
|
||||
latest_job_started: string | null;
|
||||
latest_job_completed: string | null;
|
||||
latest_products_found: number | null;
|
||||
latest_products_new: number | null;
|
||||
latest_products_updated: number | null;
|
||||
latest_job_error: string | null;
|
||||
// Dispensary info (from master AZDHS directory)
|
||||
dispensary_id: number | null;
|
||||
dispensary_name: string | null;
|
||||
dispensary_company: string | null;
|
||||
dispensary_city: string | null;
|
||||
// Provider intelligence (from dispensary)
|
||||
// Dispensary-centric schedule data (from dispensary_crawl_status view)
|
||||
interface DispensarySchedule {
|
||||
dispensary_id: number;
|
||||
dispensary_name: string;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
slug: string | null;
|
||||
website: string | null;
|
||||
menu_url: string | null;
|
||||
product_provider: string | null;
|
||||
product_confidence: number | null;
|
||||
product_crawler_mode: string | null;
|
||||
// Orchestrator status
|
||||
last_product_scan_at: string | null;
|
||||
schedule_active: boolean;
|
||||
interval_minutes: number;
|
||||
priority: number;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
last_status: string | null;
|
||||
last_summary: string | null;
|
||||
schedule_last_run: 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;
|
||||
}
|
||||
|
||||
interface CrawlJob {
|
||||
id: number;
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
dispensary_id: number;
|
||||
dispensary_name: string;
|
||||
job_type: string;
|
||||
trigger_type: string;
|
||||
status: string;
|
||||
@@ -71,12 +62,12 @@ interface CrawlJob {
|
||||
|
||||
export function ScraperSchedule() {
|
||||
const [globalSchedules, setGlobalSchedules] = useState<GlobalSchedule[]>([]);
|
||||
const [storeSchedules, setStoreSchedules] = useState<StoreSchedule[]>([]);
|
||||
const [dispensarySchedules, setDispensarySchedules] = useState<DispensarySchedule[]>([]);
|
||||
const [jobs, setJobs] = useState<CrawlJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'stores' | 'jobs' | 'global'>('stores');
|
||||
const [triggeringStore, setTriggeringStore] = useState<number | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'dispensaries' | 'jobs' | 'global'>('dispensaries');
|
||||
const [triggeringDispensary, setTriggeringDispensary] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -89,14 +80,14 @@ export function ScraperSchedule() {
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [globalData, storesData, jobsData] = await Promise.all([
|
||||
const [globalData, dispensaryData, jobsData] = await Promise.all([
|
||||
api.getGlobalSchedule(),
|
||||
api.getStoreSchedules(),
|
||||
api.getCrawlJobs(100)
|
||||
api.getDispensarySchedules(),
|
||||
api.getDispensaryCrawlJobs(100)
|
||||
]);
|
||||
|
||||
setGlobalSchedules(globalData.schedules || []);
|
||||
setStoreSchedules(storesData.stores || []);
|
||||
setDispensarySchedules(dispensaryData.dispensaries || []);
|
||||
setJobs(jobsData.jobs || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load schedule data:', error);
|
||||
@@ -105,15 +96,15 @@ export function ScraperSchedule() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerCrawl = async (storeId: number) => {
|
||||
setTriggeringStore(storeId);
|
||||
const handleTriggerCrawl = async (dispensaryId: number) => {
|
||||
setTriggeringDispensary(dispensaryId);
|
||||
try {
|
||||
await api.triggerStoreCrawl(storeId);
|
||||
await api.triggerDispensaryCrawl(dispensaryId);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger crawl:', error);
|
||||
} finally {
|
||||
setTriggeringStore(null);
|
||||
setTriggeringDispensary(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,20 +230,20 @@ export function ScraperSchedule() {
|
||||
{/* Tabs */}
|
||||
<div style={{ marginBottom: '30px', display: 'flex', gap: '10px', borderBottom: '2px solid #eee' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('stores')}
|
||||
onClick={() => setActiveTab('dispensaries')}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: activeTab === 'stores' ? 'white' : 'transparent',
|
||||
background: activeTab === 'dispensaries' ? 'white' : 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'stores' ? '3px solid #2563eb' : '3px solid transparent',
|
||||
borderBottom: activeTab === 'dispensaries' ? '3px solid #2563eb' : '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: activeTab === 'stores' ? '600' : '400',
|
||||
color: activeTab === 'stores' ? '#2563eb' : '#666',
|
||||
fontWeight: activeTab === 'dispensaries' ? '600' : '400',
|
||||
color: activeTab === 'dispensaries' ? '#2563eb' : '#666',
|
||||
marginBottom: '-2px'
|
||||
}}
|
||||
>
|
||||
Store Schedules
|
||||
Dispensary Schedules ({dispensarySchedules.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
@@ -380,7 +371,7 @@ export function ScraperSchedule() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'stores' && (
|
||||
{activeTab === 'dispensaries' && (
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
@@ -390,7 +381,7 @@ export function ScraperSchedule() {
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
|
||||
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Dispensary / Store</th>
|
||||
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Dispensary</th>
|
||||
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Provider</th>
|
||||
<th style={{ padding: '15px', textAlign: 'center', fontWeight: '600' }}>Schedule</th>
|
||||
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Last Run</th>
|
||||
@@ -400,55 +391,43 @@ export function ScraperSchedule() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{storeSchedules.map((store) => (
|
||||
<tr key={store.store_id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
{dispensarySchedules.map((disp) => (
|
||||
<tr key={disp.dispensary_id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '15px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{store.dispensary_id ? (
|
||||
{disp.state && disp.city && disp.slug ? (
|
||||
<Link
|
||||
to={`/dispensaries/${store.dispensary_id}`}
|
||||
to={`/dispensaries/${disp.state}/${disp.city.toLowerCase().replace(/\s+/g, '-')}/${disp.slug}`}
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
color: '#2563eb',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
{store.dispensary_name || store.store_name}
|
||||
{disp.dispensary_name}
|
||||
</Link>
|
||||
) : (
|
||||
<span style={{ fontWeight: '600' }}>{store.store_name}</span>
|
||||
)}
|
||||
{!store.dispensary_id && (
|
||||
<span style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '600',
|
||||
background: '#fef3c7',
|
||||
color: '#92400e'
|
||||
}}>
|
||||
Unmapped
|
||||
</span>
|
||||
<span style={{ fontWeight: '600' }}>{disp.dispensary_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#666' }}>
|
||||
{store.dispensary_city ? `${store.dispensary_city} | ${store.timezone}` : store.timezone}
|
||||
{disp.city ? `${disp.city}, ${disp.state}` : disp.state}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '15px', textAlign: 'center' }}>
|
||||
{store.product_provider ? (
|
||||
{disp.product_provider ? (
|
||||
<div>
|
||||
<span style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
background: store.product_crawler_mode === 'production' ? '#d1fae5' : '#fef3c7',
|
||||
color: store.product_crawler_mode === 'production' ? '#065f46' : '#92400e'
|
||||
background: disp.product_crawler_mode === 'production' ? '#d1fae5' : '#fef3c7',
|
||||
color: disp.product_crawler_mode === 'production' ? '#065f46' : '#92400e'
|
||||
}}>
|
||||
{store.product_provider}
|
||||
{disp.product_provider}
|
||||
</span>
|
||||
{store.product_crawler_mode !== 'production' && (
|
||||
{disp.product_crawler_mode !== 'production' && (
|
||||
<div style={{ fontSize: '10px', color: '#92400e', marginTop: '2px' }}>sandbox</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -472,31 +451,31 @@ export function ScraperSchedule() {
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
background: store.schedule_enabled && store.scrape_enabled ? '#d1fae5' : '#fee2e2',
|
||||
color: store.schedule_enabled && store.scrape_enabled ? '#065f46' : '#991b1b'
|
||||
background: disp.schedule_active ? '#d1fae5' : '#fee2e2',
|
||||
color: disp.schedule_active ? '#065f46' : '#991b1b'
|
||||
}}>
|
||||
{store.schedule_enabled && store.scrape_enabled ? 'Active' : 'Disabled'}
|
||||
{disp.schedule_active ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
Every {store.interval_hours}h
|
||||
Every {Math.round(disp.interval_minutes / 60)}h
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '15px' }}>
|
||||
<div>{formatTimeAgo(store.last_scraped_at)}</div>
|
||||
{store.last_scraped_at && (
|
||||
<div>{formatTimeAgo(disp.last_run_at)}</div>
|
||||
{disp.last_run_at && (
|
||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||
{new Date(store.last_scraped_at).toLocaleString()}
|
||||
{new Date(disp.last_run_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '15px' }}>
|
||||
<div style={{ fontWeight: '600', color: '#2563eb' }}>
|
||||
{formatTimeUntil(store.next_scheduled_run)}
|
||||
{disp.next_run_at ? formatTimeUntil(disp.next_run_at) : 'Not scheduled'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '15px' }}>
|
||||
{store.last_status || store.latest_job_status ? (
|
||||
{disp.last_status || disp.latest_job_status ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<span style={{
|
||||
@@ -504,13 +483,13 @@ export function ScraperSchedule() {
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
...getStatusColor(store.last_status || store.latest_job_status || 'pending')
|
||||
...getStatusColor(disp.last_status || disp.latest_job_status || 'pending')
|
||||
}}>
|
||||
{store.last_status || store.latest_job_status}
|
||||
{disp.last_status || disp.latest_job_status}
|
||||
</span>
|
||||
{store.last_error && (
|
||||
{disp.last_error && (
|
||||
<button
|
||||
onClick={() => alert(store.last_error)}
|
||||
onClick={() => alert(disp.last_error)}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: '#fee2e2',
|
||||
@@ -525,14 +504,13 @@ export function ScraperSchedule() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{store.last_summary ? (
|
||||
{disp.last_summary ? (
|
||||
<div style={{ fontSize: '12px', color: '#666', maxWidth: '250px' }}>
|
||||
{store.last_summary}
|
||||
{disp.last_summary}
|
||||
</div>
|
||||
) : store.latest_products_found !== null ? (
|
||||
) : disp.latest_products_found !== null ? (
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
{store.latest_products_found} products
|
||||
{store.latest_products_new !== null && ` (+${store.latest_products_new} new)`}
|
||||
{disp.latest_products_found} products
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -542,19 +520,19 @@ export function ScraperSchedule() {
|
||||
</td>
|
||||
<td style={{ padding: '15px', textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleTriggerCrawl(store.store_id)}
|
||||
disabled={triggeringStore === store.store_id}
|
||||
onClick={() => handleTriggerCrawl(disp.dispensary_id)}
|
||||
disabled={triggeringDispensary === disp.dispensary_id}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: triggeringStore === store.store_id ? '#94a3b8' : '#2563eb',
|
||||
background: triggeringDispensary === disp.dispensary_id ? '#94a3b8' : '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: triggeringStore === store.store_id ? 'wait' : 'pointer',
|
||||
cursor: triggeringDispensary === disp.dispensary_id ? 'wait' : 'pointer',
|
||||
fontSize: '13px'
|
||||
}}
|
||||
>
|
||||
{triggeringStore === store.store_id ? 'Starting...' : 'Run Now'}
|
||||
{triggeringDispensary === disp.dispensary_id ? 'Starting...' : 'Run Now'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -606,7 +584,7 @@ export function ScraperSchedule() {
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8f8f8', borderBottom: '2px solid #eee' }}>
|
||||
<th style={{ padding: '15px', textAlign: 'left', fontWeight: '600' }}>Store</th>
|
||||
<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>
|
||||
@@ -627,7 +605,7 @@ export function ScraperSchedule() {
|
||||
jobs.map((job) => (
|
||||
<tr key={job.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '15px' }}>
|
||||
<div style={{ fontWeight: '600' }}>{job.store_name}</div>
|
||||
<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' }}>
|
||||
|
||||
Reference in New Issue
Block a user