diff --git a/backend/migrations/114_schedule_pool_id.sql b/backend/migrations/114_schedule_pool_id.sql new file mode 100644 index 00000000..8d108c35 --- /dev/null +++ b/backend/migrations/114_schedule_pool_id.sql @@ -0,0 +1,10 @@ +-- Migration 114: Add pool_id to task_schedules +-- Allows schedules to target specific geo pools + +ALTER TABLE task_schedules +ADD COLUMN IF NOT EXISTS pool_id INTEGER REFERENCES task_pools(id); + +-- Index for pool-based schedule queries +CREATE INDEX IF NOT EXISTS idx_task_schedules_pool ON task_schedules(pool_id) WHERE pool_id IS NOT NULL; + +COMMENT ON COLUMN task_schedules.pool_id IS 'Optional geo pool filter. NULL = all pools/dispensaries matching state_code'; diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index db738459..e688a2f6 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -214,27 +214,29 @@ router.get('/schedules', async (req: Request, res: Response) => { const enabledOnly = req.query.enabled === 'true'; let query = ` - SELECT id, name, role, description, enabled, interval_hours, - priority, state_code, platform, method, - COALESCE(is_immutable, false) as is_immutable, - last_run_at, next_run_at, - last_task_count, last_error, created_at, updated_at - FROM task_schedules + SELECT ts.id, ts.name, ts.role, ts.description, ts.enabled, ts.interval_hours, + ts.priority, ts.state_code, ts.pool_id, tp.display_name as pool_name, + ts.platform, ts.method, + COALESCE(ts.is_immutable, false) as is_immutable, + ts.last_run_at, ts.next_run_at, + ts.last_task_count, ts.last_error, ts.created_at, ts.updated_at + FROM task_schedules ts + LEFT JOIN task_pools tp ON tp.id = ts.pool_id `; if (enabledOnly) { - query += ` WHERE enabled = true`; + query += ` WHERE ts.enabled = true`; } query += ` ORDER BY - CASE role + CASE ts.role WHEN 'store_discovery' THEN 1 WHEN 'product_discovery' THEN 2 WHEN 'analytics_refresh' THEN 3 ELSE 4 END, - state_code NULLS FIRST, - name`; + ts.state_code NULLS FIRST, + ts.name`; const result = await pool.query(query); res.json({ schedules: result.rows }); diff --git a/backend/src/tasks/task-service.ts b/backend/src/tasks/task-service.ts index 93ee733b..61ad143c 100644 --- a/backend/src/tasks/task-service.ts +++ b/backend/src/tasks/task-service.ts @@ -453,6 +453,7 @@ class TaskService { t.*, d.name as dispensary_name, d.slug as dispensary_slug, + d.menu_type as menu_type, ${poolColumns} w.friendly_name as worker_name FROM worker_tasks t diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index 68b26524..d0e86e28 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -3140,6 +3140,8 @@ export interface TaskSchedule { interval_hours: number; priority: number; state_code: string | null; + pool_id: number | null; + pool_name: string | null; platform: string | null; method: 'curl' | 'http' | null; is_immutable: boolean; diff --git a/cannaiq/src/pages/TasksDashboard.tsx b/cannaiq/src/pages/TasksDashboard.tsx index 777ecc73..94392667 100644 --- a/cannaiq/src/pages/TasksDashboard.tsx +++ b/cannaiq/src/pages/TasksDashboard.tsx @@ -27,6 +27,9 @@ import { Timer, Lock, Globe, + ArrowUpDown, + ArrowUp, + ArrowDown, } from 'lucide-react'; interface Task { @@ -34,6 +37,7 @@ interface Task { role: string; dispensary_id: number | null; dispensary_name?: string; + menu_type?: string | null; platform: string | null; status: string; priority: number; @@ -92,6 +96,7 @@ interface Store { name: string; state_code: string; crawl_enabled: boolean; + menu_type?: string; } interface CreateTaskModalProps { @@ -138,10 +143,15 @@ function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProp } }, [isOpen]); + // Clear selected stores when platform changes + useEffect(() => { + setSelectedStores([]); + }, [taskPlatform]); + const fetchStores = async () => { setStoresLoading(true); try { - const res = await api.get('/api/stores?limit=500'); + const res = await api.get('/api/stores?limit=2000'); setStores(res.data.stores || res.data || []); } catch (err) { console.error('Failed to fetch stores:', err); @@ -150,10 +160,25 @@ function CreateTaskModal({ isOpen, onClose, onTaskCreated }: CreateTaskModalProp } }; - const filteredStores = stores.filter(s => - s.name.toLowerCase().includes(storeSearch.toLowerCase()) || - s.state_code?.toLowerCase().includes(storeSearch.toLowerCase()) - ); + // Filter stores by platform (menu_type) and search query + const filteredStores = stores.filter(s => { + // Platform filter: match menu_type to selected platform + let platformMatch = true; + if (taskPlatform === 'dutchie') { + platformMatch = s.menu_type === 'dutchie_plus' || s.menu_type === 'dutchie_iframe' || s.menu_type === 'dutchie'; + } else if (taskPlatform === 'jane') { + platformMatch = s.menu_type === 'jane'; + } else if (taskPlatform === 'treez') { + platformMatch = s.menu_type === 'treez'; + } + + // Search filter + const searchMatch = !storeSearch || + s.name.toLowerCase().includes(storeSearch.toLowerCase()) || + s.state_code?.toLowerCase().includes(storeSearch.toLowerCase()); + + return platformMatch && searchMatch; + }); const toggleStore = (store: Store) => { if (selectedStores.find(s => s.id === store.id)) { @@ -939,6 +964,10 @@ export default function TasksDashboard() { const [searchQuery, setSearchQuery] = useState(''); const [showCapacity, setShowCapacity] = useState(true); + // Sorting + const [sortColumn, setSortColumn] = useState('created_at'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + // Pools for filter dropdown const [pools, setPools] = useState([]); @@ -1130,9 +1159,66 @@ export default function TasksDashboard() { return true; }); + // Sorting + const sortedTasks = [...filteredTasks].sort((a, b) => { + const dir = sortDirection === 'asc' ? 1 : -1; + switch (sortColumn) { + case 'id': + return (a.id - b.id) * dir; + case 'role': + return a.role.localeCompare(b.role) * dir; + case 'store': + return (a.dispensary_name || '').localeCompare(b.dispensary_name || '') * dir; + case 'status': + return a.status.localeCompare(b.status) * dir; + case 'worker': + return (getWorkerName(a)).localeCompare(getWorkerName(b)) * dir; + case 'duration': + const aDur = a.duration_sec ?? 0; + const bDur = b.duration_sec ?? 0; + return (aDur - bDur) * dir; + case 'created_at': + return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * dir; + default: + return 0; + } + }); + + // Handle column header click for sorting + const handleSort = (column: string) => { + if (sortColumn === column) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortDirection('desc'); + } + setPage(0); // Reset to first page when sorting + }; + + // Sortable header component + const SortHeader = ({ column, children }: { column: string; children: React.ReactNode }) => ( + handleSort(column)} + > +
+ {children} + {sortColumn === column ? ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} +
+ + ); + // Pagination - const paginatedTasks = filteredTasks.slice(page * tasksPerPage, (page + 1) * tasksPerPage); - const totalPages = Math.ceil(filteredTasks.length / tasksPerPage); + const paginatedTasks = sortedTasks.slice(page * tasksPerPage, (page + 1) * tasksPerPage); + const totalPages = Math.ceil(sortedTasks.length / tasksPerPage); const totalActive = (counts?.claimed || 0) + (counts?.running || 0); const totalPending = counts?.pending || 0; @@ -1164,6 +1250,27 @@ export default function TasksDashboard() {
+ {/* Pool Toggle Button */} + + {/* Create Task Button */}