diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index aceb5e6c..a8a7a136 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -838,14 +838,133 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): """Filter by industry and sector if provided.""" queryset = super().get_queryset() industry_id = self.request.query_params.get('industry_id') + industry_name = self.request.query_params.get('industry_name') sector_id = self.request.query_params.get('sector_id') + sector_name = self.request.query_params.get('sector_name') if industry_id: queryset = queryset.filter(industry_id=industry_id) + if industry_name: + queryset = queryset.filter(industry__name__icontains=industry_name) if sector_id: queryset = queryset.filter(sector_id=sector_id) + if sector_name: + queryset = queryset.filter(sector__name__icontains=sector_name) return queryset + + @action(detail=False, methods=['post'], url_path='import_seed_keywords', url_name='import_seed_keywords') + def import_seed_keywords(self, request): + """ + Import seed keywords from CSV (Admin/Superuser only). + Expected columns: keyword, industry_name, sector_name, volume, difficulty, intent + """ + import csv + from django.db import transaction + + # Check admin/superuser permission + if not (request.user.is_staff or request.user.is_superuser): + return error_response( + error='Admin or superuser access required', + status_code=status.HTTP_403_FORBIDDEN, + request=request + ) + + if 'file' not in request.FILES: + return error_response( + error='No file provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + file = request.FILES['file'] + if not file.name.endswith('.csv'): + return error_response( + error='File must be a CSV', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + try: + # Parse CSV + decoded_file = file.read().decode('utf-8') + csv_reader = csv.DictReader(decoded_file.splitlines()) + + imported_count = 0 + skipped_count = 0 + errors = [] + + with transaction.atomic(): + for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (header is row 1) + try: + keyword_text = row.get('keyword', '').strip() + industry_name = row.get('industry_name', '').strip() + sector_name = row.get('sector_name', '').strip() + + if not all([keyword_text, industry_name, sector_name]): + skipped_count += 1 + continue + + # Get or create industry + industry = Industry.objects.filter(name=industry_name).first() + if not industry: + errors.append(f"Row {row_num}: Industry '{industry_name}' not found") + skipped_count += 1 + continue + + # Get or create industry sector + sector = IndustrySector.objects.filter( + industry=industry, + name=sector_name + ).first() + if not sector: + errors.append(f"Row {row_num}: Sector '{sector_name}' not found for industry '{industry_name}'") + skipped_count += 1 + continue + + # Check if keyword already exists + existing = SeedKeyword.objects.filter( + keyword=keyword_text, + industry=industry, + sector=sector + ).first() + + if existing: + skipped_count += 1 + continue + + # Create seed keyword + SeedKeyword.objects.create( + keyword=keyword_text, + industry=industry, + sector=sector, + volume=int(row.get('volume', 0) or 0), + difficulty=int(row.get('difficulty', 0) or 0), + intent=row.get('intent', 'informational') or 'informational', + is_active=True + ) + imported_count += 1 + + except Exception as e: + errors.append(f"Row {row_num}: {str(e)}") + skipped_count += 1 + + return success_response( + data={ + 'imported': imported_count, + 'skipped': skipped_count, + 'errors': errors[:10] if errors else [] # Limit errors to first 10 + }, + message=f'Import completed: {imported_count} keywords imported, {skipped_count} skipped', + request=request + ) + + except Exception as e: + return error_response( + error=f'Failed to import keywords: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) # ============================================================================ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f72f89de..07a93f60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -356,11 +356,13 @@ export default function App() { } /> {/* Setup Pages */} - } /> + {/* Legacy redirect */} + } /> {/* Automation Module - Redirect dashboard to rules */} } /> diff --git a/frontend/src/config/import-export.config.tsx b/frontend/src/config/import-export.config.tsx index 1be8457f..398a9ad9 100644 --- a/frontend/src/config/import-export.config.tsx +++ b/frontend/src/config/import-export.config.tsx @@ -159,6 +159,11 @@ export function useImportExport(

Upload a CSV file (max {maxFileSize / 1024 / 1024}MB)

+ {filename === 'keywords' && ( +

+ Expected columns: keyword, volume, difficulty, intent, status +

+ )}
diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 996f4ba5..b0d3ced8 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -85,8 +85,8 @@ const AppSidebar: React.FC = () => { const setupItems: NavItem[] = [ { icon: , - name: "Industry, Sectors & Keywords", - path: "/setup/industries-sectors-keywords", // Merged page + name: "Add Keywords", + path: "/setup/add-keywords", }, { icon: , diff --git a/frontend/src/pages/Settings/MasterStatus.tsx b/frontend/src/pages/Settings/MasterStatus.tsx index 1349daa5..38a4cdd7 100644 --- a/frontend/src/pages/Settings/MasterStatus.tsx +++ b/frontend/src/pages/Settings/MasterStatus.tsx @@ -260,11 +260,32 @@ export default function MasterStatus() { setLoading(false); }, [fetchSystemMetrics, fetchApiHealth, checkWorkflowHealth, checkIntegrationHealth]); - // Initial load and auto-refresh + // Initial load and auto-refresh (pause when page not visible) useEffect(() => { + let interval: NodeJS.Timeout; + + const handleVisibilityChange = () => { + if (document.hidden) { + // Page not visible - clear interval + if (interval) clearInterval(interval); + } else { + // Page visible - refresh and restart interval + refreshAll(); + interval = setInterval(refreshAll, 30000); + } + }; + + // Initial setup refreshAll(); - const interval = setInterval(refreshAll, 30000); // 30 seconds - return () => clearInterval(interval); + interval = setInterval(refreshAll, 30000); + + // Listen for visibility changes + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; }, [refreshAll]); // Status badge component diff --git a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx index aa2e4bad..da91d2cb 100644 --- a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx +++ b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx @@ -1,626 +1,755 @@ /** - * Industries, Sectors & Keywords Setup Page - * Merged page combining: - * - Industry selection - * - Sector selection (from selected industry) - * - Keyword opportunities (filtered by selected industry/sectors) - * - * Saves selections to user account for use in site creation + * Add Keywords Page + * Browse global seed keywords filtered by site's industry and sectors + * Add keywords to workflow (creates Keywords records in planner) + * Admin users can import seed keywords via CSV */ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import PageMeta from '../../components/common/PageMeta'; import PageHeader from '../../components/common/PageHeader'; -import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs'; import { useToast } from '../../components/ui/toast/ToastContainer'; +import WorkflowGuide from '../../components/onboarding/WorkflowGuide'; import { - fetchIndustries, - Industry, fetchSeedKeywords, SeedKeyword, - fetchAccountSetting, - createAccountSetting, - updateAccountSetting, - AccountSettingsError + SeedKeywordResponse, + fetchSites, + Site, + addSeedKeywordsToWorkflow, } from '../../services/api'; -import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; -import Button from '../../components/ui/button/Button'; -import { PieChartIcon, CheckCircleIcon, BoltIcon } from '../../icons'; -import { Tooltip } from '../../components/ui/tooltip/Tooltip'; -import { Tabs, TabList, Tab, TabPanel } from '../../components/ui/tabs/Tabs'; +import { BoltIcon, PlusIcon } from '../../icons'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { usePageSizeStore } from '../../store/pageSizeStore'; -import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty'; - -interface IndustryWithData extends Industry { - keywordsCount: number; - totalVolume: number; - sectors?: Array<{ slug: string; name: string; description?: string }>; -} - -interface UserPreferences { - selectedIndustry?: string; - selectedSectors?: string[]; - selectedKeywords?: number[]; -} - -// Format volume with k for thousands and m for millions -const formatVolume = (volume: number): string => { - if (volume >= 1000000) { - return `${(volume / 1000000).toFixed(1)}m`; - } else if (volume >= 1000) { - return `${(volume / 1000).toFixed(1)}k`; - } - return volume.toString(); -}; - -const getAccountSettingsPreferenceMessage = (error: AccountSettingsError): string => { - switch (error.type) { - case 'ACCOUNT_SETTINGS_VALIDATION_ERROR': - return error.message || 'The saved preferences could not be loaded because the data is invalid.'; - default: - return error.message || 'Unable to load your saved preferences right now.'; - } -}; +import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty'; +import { useSiteStore } from '../../store/siteStore'; +import { useSectorStore } from '../../store/sectorStore'; +import { useAuthStore } from '../../store/authStore'; +import Button from '../../components/ui/button/Button'; +import { Modal } from '../../components/ui/modal'; +import FileInput from '../../components/form/input/FileInput'; +import Label from '../../components/form/Label'; export default function IndustriesSectorsKeywords() { const toast = useToast(); + const { activeSite } = useSiteStore(); + const { activeSector, loadSectorsForSite } = useSectorStore(); const { pageSize } = usePageSizeStore(); + const { user } = useAuthStore(); // Data state - const [industries, setIndustries] = useState([]); - const [selectedIndustry, setSelectedIndustry] = useState(null); - const [selectedSectors, setSelectedSectors] = useState([]); - const [seedKeywords, setSeedKeywords] = useState([]); + const [sites, setSites] = useState([]); + const [seedKeywords, setSeedKeywords] = useState<(SeedKeyword & { isAdded?: boolean })[]>([]); const [loading, setLoading] = useState(true); - const [keywordsLoading, setKeywordsLoading] = useState(false); - const [saving, setSaving] = useState(false); - - // Keywords table state + const [showContent, setShowContent] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + // Track recently added keywords to preserve their state during reload + const recentlyAddedRef = useRef>(new Set()); + + // Pagination state const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [totalCount, setTotalCount] = useState(0); + + // Sorting state + const [sortBy, setSortBy] = useState('keyword'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + // Filter state const [searchTerm, setSearchTerm] = useState(''); const [intentFilter, setIntentFilter] = useState(''); const [difficultyFilter, setDifficultyFilter] = useState(''); - const [selectedKeywordIds, setSelectedKeywordIds] = useState([]); - // Load industries on mount + // Check if user is admin/superuser (role is 'admin' or 'developer') + const isAdmin = user?.role === 'admin' || user?.role === 'developer'; + + // Import modal state + const [isImportModalOpen, setIsImportModalOpen] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [importFile, setImportFile] = useState(null); + + // Load sites on mount useEffect(() => { - loadIndustries(); - loadUserPreferences(); + loadInitialData(); }, []); - // Load user preferences from account settings - const loadUserPreferences = async () => { - try { - const setting = await fetchAccountSetting('user_preferences'); - const preferences = setting.config as UserPreferences | undefined; - if (preferences) { - if (preferences.selectedIndustry) { - // Find and select the industry - const industry = industries.find(i => i.slug === preferences.selectedIndustry); - if (industry) { - setSelectedIndustry(industry); - if (preferences.selectedSectors) { - setSelectedSectors(preferences.selectedSectors); - } - } - } - } - } catch (error: any) { - if (error instanceof AccountSettingsError) { - if (error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') { - // Preferences don't exist yet - this is expected for new users - return; - } - // For other errors (500, etc.), silently handle - user can still use the page - // Don't show error toast for server errors - graceful degradation - return; - } - // For non-AccountSettingsError errors, silently handle - graceful degradation - } - }; - - // Load industries - const loadIndustries = async () => { + const loadInitialData = async () => { try { setLoading(true); - const response = await fetchIndustries(); - const industriesList = response.industries || []; - - // Fetch keywords to calculate counts - let allKeywords: SeedKeyword[] = []; - try { - const keywordsResponse = await fetchSeedKeywords({ - page_size: 1000, - }); - allKeywords = keywordsResponse.results || []; - } catch (error) { - console.warn('Failed to fetch keywords for counts:', error); - } - - // Process each industry with its keywords data - const industriesWithData = industriesList.map((industry) => { - const industryKeywords = allKeywords.filter( - (kw: SeedKeyword) => kw.industry_name === industry.name - ); - - const totalVolume = industryKeywords.reduce( - (sum, kw) => sum + (kw.volume || 0), - 0 - ); - - return { - ...industry, - keywordsCount: industryKeywords.length, - totalVolume, - }; - }); - - setIndustries(industriesWithData.filter(i => i.keywordsCount > 0)); + const response = await fetchSites(); + const activeSites = (response.results || []).filter(site => site.is_active); + setSites(activeSites); } catch (error: any) { - toast.error(`Failed to load industries: ${error.message}`); + toast.error(`Failed to load sites: ${error.message}`); } finally { setLoading(false); } }; - // Load sectors for selected industry - const loadSectorsForIndustry = useCallback(async () => { - if (!selectedIndustry) { - return; - } + // Handle site added from WorkflowGuide + const handleSiteAdded = () => { + loadInitialData(); + }; - try { - // Fetch sectors from the industry's related data - // The industry object should have sectors in the response - const response = await fetchIndustries(); - const industryData = response.industries?.find(i => i.slug === selectedIndustry.slug); - if (industryData?.sectors) { - // Sectors are already in the industry data - } - } catch (error: any) { - console.error('Error loading sectors:', error); - } - }, [selectedIndustry]); - - // Load keywords when industry/sectors change + // Load sectors for active site useEffect(() => { - if (selectedIndustry) { - loadKeywords(); - } else { - setSeedKeywords([]); - setTotalCount(0); + if (activeSite?.id) { + loadSectorsForSite(activeSite.id); } - }, [selectedIndustry, selectedSectors, currentPage, searchTerm, intentFilter, difficultyFilter]); + }, [activeSite?.id, loadSectorsForSite]); // Load seed keywords - const loadKeywords = async () => { - if (!selectedIndustry) { + const loadSeedKeywords = useCallback(async () => { + if (!activeSite || !activeSite.industry) { + setSeedKeywords([]); + setTotalCount(0); + setTotalPages(1); + setShowContent(true); return; } - setKeywordsLoading(true); + setShowContent(false); + try { - const filters: any = { - industry_name: selectedIndustry.name, - page: currentPage, - page_size: pageSize, + // Get already-attached keywords across ALL sectors for this site + let attachedSeedKeywordIds = new Set(); + try { + const { fetchKeywords, fetchSiteSectors } = await import('../../services/api'); + // Get all sectors for the site + const sectors = await fetchSiteSectors(activeSite.id); + + // Check keywords in all sectors + for (const sector of sectors) { + try { + const keywordsData = await fetchKeywords({ + site_id: activeSite.id, + sector_id: sector.id, + page_size: 1000, + }); + (keywordsData.results || []).forEach((k: any) => { + const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id); + if (seedKeywordId) { + attachedSeedKeywordIds.add(Number(seedKeywordId)); + } + }); + } catch (err) { + console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err); + } + } + } catch (err) { + console.warn('Could not fetch sectors or attached keywords:', err); + } + + // Build filters - fetch ALL results by paginating through all pages + const baseFilters: any = { + industry: activeSite.industry, + page_size: 1000, }; - if (selectedSectors.length > 0) { - // Filter by selected sectors if any - // Note: API might need sector_name filter + // Add sector filter if active sector is selected + if (activeSector && activeSector.industry_sector) { + baseFilters.sector = activeSector.industry_sector; } - if (searchTerm) { - filters.search = searchTerm; - } + if (searchTerm) baseFilters.search = searchTerm; + if (intentFilter) baseFilters.intent = intentFilter; - if (intentFilter) { - filters.intent = intentFilter; + // Fetch ALL pages to get complete dataset + let allResults: SeedKeyword[] = []; + let currentPageNum = 1; + let hasMore = true; + + while (hasMore) { + const filters = { ...baseFilters, page: currentPageNum }; + const data: SeedKeywordResponse = await fetchSeedKeywords(filters); + + if (data.results && data.results.length > 0) { + allResults = [...allResults, ...data.results]; + } + + hasMore = data.next !== null && data.next !== undefined; + currentPageNum++; + + if (currentPageNum > 100) { + console.warn('Reached maximum page limit (100) while fetching seed keywords'); + break; + } } - + + // Mark already-attached keywords + let filteredResults = allResults.map(sk => { + const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id)); + return { + ...sk, + isAdded: Boolean(isAdded) + }; + }); + + // Apply difficulty filter if (difficultyFilter) { const difficultyNum = parseInt(difficultyFilter); const label = getDifficultyLabelFromNumber(difficultyNum); if (label !== null) { const range = getDifficultyRange(label); if (range) { - filters.difficulty_min = range.min; - filters.difficulty_max = range.max; + filteredResults = filteredResults.filter( + sk => sk.difficulty >= range.min && sk.difficulty <= range.max + ); } } } - const data = await fetchSeedKeywords(filters); - setSeedKeywords(data.results || []); - setTotalCount(data.count || 0); - setTotalPages(Math.ceil((data.count || 0) / pageSize)); + // Apply client-side sorting + if (sortBy) { + filteredResults.sort((a, b) => { + let aVal: any; + let bVal: any; + + if (sortBy === 'keyword') { + aVal = a.keyword.toLowerCase(); + bVal = b.keyword.toLowerCase(); + } else if (sortBy === 'volume') { + aVal = a.volume; + bVal = b.volume; + } else if (sortBy === 'difficulty') { + aVal = a.difficulty; + bVal = b.difficulty; + } else if (sortBy === 'intent') { + aVal = a.intent.toLowerCase(); + bVal = b.intent.toLowerCase(); + } else { + return 0; + } + + if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; + if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + } + + // Calculate total count and pages from filtered results + const totalFiltered = filteredResults.length; + const pageSizeNum = pageSize || 10; + + // Apply client-side pagination + const startIndex = (currentPage - 1) * pageSizeNum; + const endIndex = startIndex + pageSizeNum; + const paginatedResults = filteredResults.slice(startIndex, endIndex); + + setSeedKeywords(paginatedResults); + setTotalCount(totalFiltered); + setTotalPages(Math.ceil(totalFiltered / pageSizeNum)); + + setShowContent(true); } catch (error: any) { + console.error('Error loading seed keywords:', error); toast.error(`Failed to load keywords: ${error.message}`); - } finally { - setKeywordsLoading(false); + setSeedKeywords([]); + setTotalCount(0); + setTotalPages(1); } - }; + }, [activeSite, activeSector, currentPage, pageSize, searchTerm, intentFilter, difficultyFilter, sortBy, sortDirection, toast]); - // Handle industry selection - const handleIndustrySelect = (industry: Industry) => { - setSelectedIndustry(industry); - setSelectedSectors([]); // Reset sectors when industry changes + // Load data on mount and when filters change + useEffect(() => { + if (activeSite) { + loadSeedKeywords(); + } + }, [loadSeedKeywords, activeSite]); + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => { + setCurrentPage(1); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + // Reset to page 1 on pageSize change + useEffect(() => { + setCurrentPage(1); + }, [pageSize]); + + // Handle sorting + const handleSort = (field: string, direction: 'asc' | 'desc') => { + setSortBy(field || 'keyword'); + setSortDirection(direction); setCurrentPage(1); }; - // Navigation tabs for Industries/Sectors/Keywords - const setupTabs = [ - { label: 'Industries', path: '#industries', icon: }, - { label: 'Sectors', path: '#sectors', icon: }, - { label: 'Keywords', path: '#keywords', icon: }, - ]; - - // Handle sector toggle - const handleSectorToggle = (sectorSlug: string) => { - setSelectedSectors(prev => - prev.includes(sectorSlug) - ? prev.filter(s => s !== sectorSlug) - : [...prev, sectorSlug] - ); - setCurrentPage(1); - }; - - // Save preferences to account - const handleSavePreferences = async () => { - if (!selectedIndustry) { - toast.error('Please select an industry first'); + // Handle adding keywords to workflow + const handleAddToWorkflow = useCallback(async (seedKeywordIds: number[]) => { + if (!activeSite) { + toast.error('Please select an active site first'); return; } - setSaving(true); - try { - const preferences: UserPreferences = { - selectedIndustry: selectedIndustry.slug, - selectedSectors: selectedSectors, - selectedKeywords: selectedKeywordIds.map(id => parseInt(id)), - }; - - // Try to update existing setting, or create if it doesn't exist + // Get sector to use + let sectorToUse = activeSector; + if (!sectorToUse) { try { - await updateAccountSetting('user_preferences', { - config: preferences, - is_active: true, - }); - } catch (error: any) { - if (error instanceof AccountSettingsError && error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') { - await createAccountSetting({ - key: 'user_preferences', - config: preferences, - is_active: true, - }); - } else { - throw error; + const { fetchSiteSectors } = await import('../../services/api'); + const sectors = await fetchSiteSectors(activeSite.id); + if (sectors.length === 0) { + toast.error('No sectors available for this site. Please create a sector first.'); + return; } + sectorToUse = { + id: sectors[0].id, + name: sectors[0].name, + slug: sectors[0].slug, + site_id: activeSite.id, + is_active: sectors[0].is_active !== false, + industry_sector: sectors[0].industry_sector || null, + }; + } catch (error: any) { + toast.error(`Failed to get sectors: ${error.message}`); + return; } - - toast.success('Preferences saved successfully! These will be used when creating new sites.'); - } catch (error: any) { - if (error instanceof AccountSettingsError) { - toast.error(getAccountSettingsPreferenceMessage(error)); - } else { - toast.error(`Failed to save preferences: ${error.message}`); - } - } finally { - setSaving(false); } - }; - // Get available sectors for selected industry - const availableSectors = useMemo(() => { - if (!selectedIndustry) return []; - // Get sectors from industry data or fetch separately - // For now, we'll use the industry's sectors if available - return selectedIndustry.sectors || []; - }, [selectedIndustry]); + try { + const result = await addSeedKeywordsToWorkflow( + seedKeywordIds, + activeSite.id, + sectorToUse.id + ); - // Keywords table columns - const keywordColumns = useMemo(() => [ - { - key: 'keyword', - label: 'Keyword', - sortable: true, - render: (row: SeedKeyword) => ( -
- {row.keyword} + if (result.success) { + toast.success(`Successfully added ${result.created} keyword(s) to workflow`); + + // Track as recently added + seedKeywordIds.forEach(id => { + recentlyAddedRef.current.add(id); + }); + + // Clear selection + setSelectedIds([]); + + // Update state immediately + setSeedKeywords(prevKeywords => + prevKeywords.map(kw => + seedKeywordIds.includes(kw.id) + ? { ...kw, isAdded: true } + : kw + ) + ); + } else { + toast.error(`Failed to add keywords: ${result.errors?.join(', ') || 'Unknown error'}`); + } + } catch (error: any) { + toast.error(`Failed to add keywords: ${error.message}`); + } + }, [activeSite, activeSector, toast]); + + // Handle bulk add selected + const handleBulkAddSelected = useCallback(async (ids: string[]) => { + if (ids.length === 0) { + toast.error('Please select at least one keyword'); + return; + } + + // Filter out already added keywords + const availableIds = ids.filter(id => { + const keyword = seedKeywords.find(sk => String(sk.id) === id); + return keyword && !keyword.isAdded; + }); + + if (availableIds.length === 0) { + toast.error('All selected keywords are already added to workflow'); + return; + } + + if (availableIds.length < ids.length) { + toast.info(`${ids.length - availableIds.length} keyword(s) were already added and were skipped`); + } + + const seedKeywordIds = availableIds.map(id => parseInt(id)); + await handleAddToWorkflow(seedKeywordIds); + }, [handleAddToWorkflow, toast, seedKeywords]); + + // Handle add all + const handleAddAll = useCallback(async () => { + if (!activeSite || !activeSite.industry) { + toast.error('Please select an active site first'); + return; + } + + const availableKeywords = seedKeywords.filter(sk => !sk.isAdded); + + if (availableKeywords.length === 0) { + toast.error('All keywords are already added to workflow'); + return; + } + + const seedKeywordIds = availableKeywords.map(sk => sk.id); + await handleAddToWorkflow(seedKeywordIds); + }, [activeSite, seedKeywords, handleAddToWorkflow, toast]); + + // Handle import click + const handleImportClick = useCallback(() => { + setIsImportModalOpen(true); + }, []); + + // Handle import file upload + const handleImportSubmit = useCallback(async () => { + if (!importFile) { + toast.error('Please select a file to import'); + return; + } + + setIsImporting(true); + try { + const formData = new FormData(); + formData.append('file', importFile); + + const response = await fetch('/api/v1/auth/seed-keywords/import_seed_keywords/', { + method: 'POST', + headers: { + 'Authorization': `Token ${localStorage.getItem('authToken')}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Import failed'); + } + + const result = await response.json(); + toast.success(`Successfully imported ${result.created || 0} keywords`); + + // Reset and close modal + setImportFile(null); + setIsImportModalOpen(false); + + // Reload keywords + if (activeSite) { + loadSeedKeywords(); + } + } catch (error: any) { + console.error('Import error:', error); + toast.error(`Import failed: ${error.message}`); + } finally { + setIsImporting(false); + } + }, [importFile, toast, activeSite, loadSeedKeywords]); + + // Page config + const pageConfig = useMemo(() => { + const showSectorColumn = !activeSector; + + return { + columns: [ + { + key: 'keyword', + label: 'Keyword', + sortable: true, + sortField: 'keyword', + }, + ...(showSectorColumn ? [{ + key: 'sector_name', + label: 'Sector', + sortable: false, + render: (_value: string, row: SeedKeyword) => ( + + {row.sector_name || '-'} + + ), + }] : []), + { + key: 'volume', + label: 'Volume', + sortable: true, + sortField: 'volume', + render: (value: number) => value.toLocaleString(), + }, + { + key: 'difficulty', + label: 'Difficulty', + sortable: true, + sortField: 'difficulty', + align: 'center' as const, + render: (value: number) => { + const difficultyNum = getDifficultyNumber(value); + const difficultyBadgeVariant = 'light'; + const difficultyBadgeColor = + typeof difficultyNum === 'number' && difficultyNum === 1 + ? 'success' + : typeof difficultyNum === 'number' && difficultyNum === 2 + ? 'success' + : typeof difficultyNum === 'number' && difficultyNum === 3 + ? 'warning' + : typeof difficultyNum === 'number' && difficultyNum === 4 + ? 'error' + : typeof difficultyNum === 'number' && difficultyNum === 5 + ? 'error' + : 'light'; + return typeof difficultyNum === 'number' ? ( + + {difficultyNum} + + ) : ( + difficultyNum + ); + }, + }, + { + key: 'intent', + label: 'Intent', + sortable: true, + sortField: 'intent', + render: (value: string) => { + const getIntentColor = (intent: string) => { + const lowerIntent = intent?.toLowerCase() || ''; + if (lowerIntent === 'transactional' || lowerIntent === 'commercial') { + return 'success'; + } else if (lowerIntent === 'navigational') { + return 'warning'; + } + return 'info'; + }; + + return ( + + {value} + + ); + }, + }, + { + key: 'actions', + label: '', + sortable: false, + align: 'right' as const, + render: (_value: any, row: SeedKeyword & { isAdded?: boolean }) => ( + + ), + }, + ], + filters: [ + { + key: 'search', + label: 'Search', + type: 'text' as const, + placeholder: 'Search keywords...', + }, + { + key: 'intent', + label: 'Intent', + type: 'select' as const, + options: [ + { value: '', label: 'All Intent' }, + { value: 'informational', label: 'Informational' }, + { value: 'navigational', label: 'Navigational' }, + { value: 'transactional', label: 'Transactional' }, + { value: 'commercial', label: 'Commercial' }, + ], + }, + { + key: 'difficulty', + label: 'Difficulty', + type: 'select' as const, + options: [ + { value: '', label: 'All Difficulty' }, + { value: '1', label: '1 - Very Easy' }, + { value: '2', label: '2 - Easy' }, + { value: '3', label: '3 - Medium' }, + { value: '4', label: '4 - Hard' }, + { value: '5', label: '5 - Very Hard' }, + ], + }, + ], + bulkActions: [ + { + key: 'add_selected_to_workflow', + label: 'Add Selected to Workflow', + variant: 'primary' as const, + }, + ], + }; + }, [activeSector, handleAddToWorkflow]); + + // Show loading state + if (loading) { + return ( + <> + + , color: 'blue' }} + /> +
+
+
Loading...
+
- ), - }, - { - key: 'industry_name', - label: 'Industry', - sortable: true, - render: (row: SeedKeyword) => ( - {row.industry_name} - ), - }, - { - key: 'sector_name', - label: 'Sector', - sortable: true, - render: (row: SeedKeyword) => ( - {row.sector_name} - ), - }, - { - key: 'volume', - label: 'Volume', - sortable: true, - render: (row: SeedKeyword) => ( - - {row.volume ? formatVolume(row.volume) : '-'} - - ), - }, - { - key: 'difficulty', - label: 'Difficulty', - sortable: true, - render: (row: SeedKeyword) => { - const difficulty = row.difficulty || 0; - const label = difficulty < 30 ? 'Easy' : difficulty < 70 ? 'Medium' : 'Hard'; - const color = difficulty < 30 ? 'success' : difficulty < 70 ? 'warning' : 'error'; - return {label}; - }, - }, - { - key: 'intent', - label: 'Intent', - sortable: true, - render: (row: SeedKeyword) => ( - {row.intent || 'N/A'} - ), - }, - ], []); + + ); + } + + // Show WorkflowGuide if no sites + if (sites.length === 0) { + return ( + <> + + , color: 'blue' }} + /> +
+ +
+ + ); + } return ( <> - + , color: 'blue' }} - hideSiteSector={true} - navigation={} + title="Add Keywords" + badge={{ icon: , color: 'blue' }} + /> + { + const stringValue = value === null || value === undefined ? '' : String(value); + + if (key === 'search') { + setSearchTerm(stringValue); + } else if (key === 'intent') { + setIntentFilter(stringValue); + setCurrentPage(1); + } else if (key === 'difficulty') { + setDifficultyFilter(stringValue); + setCurrentPage(1); + } + }} + onBulkAction={async (actionKey: string, ids: string[]) => { + if (actionKey === 'add_selected_to_workflow') { + await handleBulkAddSelected(ids); + } + }} + bulkActions={pageConfig.bulkActions} + customActions={ + isAdmin ? ( + + ) : undefined + } + pagination={{ + currentPage, + totalPages, + totalCount, + onPageChange: setCurrentPage, + }} + sorting={{ + sortBy, + sortDirection, + onSort: handleSort, + }} + selection={{ + selectedIds, + onSelectionChange: setSelectedIds, + }} + // Only show row actions for admin users + onEdit={isAdmin ? undefined : undefined} + onDelete={undefined} /> -
- - {(activeTab, setActiveTab) => ( - <> - - setActiveTab('industries')}> - Industries - - setActiveTab('sectors')} disabled={!selectedIndustry}> - Sectors {selectedIndustry && `(${selectedSectors.length})`} - - setActiveTab('keywords')} disabled={!selectedIndustry}> - Keywords {selectedIndustry && `(${totalCount})`} - - + {/* Import Modal */} + { + if (!isImporting) { + setIsImportModalOpen(false); + setImportFile(null); + } + }} + > +
+

+ Import Seed Keywords +

+
+ +

+ Expected columns: keyword, volume, difficulty, intent, industry_name, sector_name +

+ { + const file = e.target.files?.[0]; + if (file) { + setImportFile(file); + } + }} + disabled={isImporting} + /> + {importFile && ( +

+ Selected: {importFile.name} +

+ )} +
- -
-
-

- Select Your Industry -

-

- Choose the industry that best matches your business. This will be used as a default when creating new sites. -

-
- - {loading ? ( -
-
Loading industries...
-
- ) : ( -
- {industries.map((industry) => ( - handleIndustrySelect(industry)} - > -
-

- {industry.name} -

- {selectedIndustry?.slug === industry.slug && ( - - )} -
- - {industry.description && ( -

- {industry.description} -

- )} - -
-
- {industry.sectors?.length || 0} - sectors -
-
- {industry.keywordsCount || 0} - keywords -
-
- - {industry.totalVolume > 0 && ( -
-
- - Total volume: {formatVolume(industry.totalVolume)} -
-
- )} -
- ))} -
- )} -
-
- - -
-
-

- Select Sectors for {selectedIndustry?.name} -

-

- Choose one or more sectors within your selected industry. These will be used as defaults when creating new sites. -

-
- - {!selectedIndustry ? ( -
- Please select an industry first -
- ) : availableSectors.length === 0 ? ( -
- No sectors available for this industry -
- ) : ( -
- {availableSectors.map((sector) => ( - handleSectorToggle(sector.slug)} - > -
-

- {sector.name} -

- {selectedSectors.includes(sector.slug) && ( - - )} -
- {sector.description && ( -

- {sector.description} -

- )} -
- ))} -
- )} -
-
- - -
-
-

- Keyword Opportunities for {selectedIndustry?.name} -

-

- Browse and select keywords that match your selected industry and sectors. These will be available when creating new sites. -

-
- - {!selectedIndustry ? ( -
- Please select an industry first -
- ) : ( - { - if (key === 'search') setSearchTerm(value as string); - else if (key === 'intent') setIntentFilter(value as string); - else if (key === 'difficulty') setDifficultyFilter(value as string); - setCurrentPage(1); - }} - pagination={{ - currentPage, - totalPages, - totalCount, - onPageChange: setCurrentPage, - }} - selection={{ - selectedIds: selectedKeywordIds, - onSelectionChange: setSelectedKeywordIds, - }} - /> - )} -
-
- - )} - - - {/* Save Button */} - {selectedIndustry && ( -
+
+
- )} -
+
+
); } diff --git a/frontend/src/templates/TablePageTemplate.tsx b/frontend/src/templates/TablePageTemplate.tsx index 3785e85b..a4a9b640 100644 --- a/frontend/src/templates/TablePageTemplate.tsx +++ b/frontend/src/templates/TablePageTemplate.tsx @@ -133,6 +133,15 @@ interface TablePageTemplateProps { onRowAction?: (actionKey: string, row: any) => Promise; getItemDisplayName?: (row: any) => string; // Function to get display name from row (e.g., row.keyword or row.name) className?: string; + // Custom actions to display in action buttons area (near column selector) + customActions?: ReactNode; + // Custom bulk actions configuration (overrides table-actions.config.ts) + bulkActions?: Array<{ + key: string; + label: string; + icon?: ReactNode; + variant?: 'primary' | 'success' | 'danger'; + }>; } export default function TablePageTemplate({ @@ -167,6 +176,8 @@ export default function TablePageTemplate({ onExport, getItemDisplayName = (row: any) => row.name || row.keyword || row.title || String(row.id), className = '', + customActions, + bulkActions: customBulkActions, }: TablePageTemplateProps) { const location = useLocation(); const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false); @@ -181,7 +192,8 @@ export default function TablePageTemplate({ // Get actions from config (edit/delete always included) const rowActions = tableActionsConfig?.rowActions || []; - const bulkActions = tableActionsConfig?.bulkActions || []; + // Use custom bulk actions if provided, otherwise use config + const bulkActions = customBulkActions || tableActionsConfig?.bulkActions || []; // Selection and expanded rows state const [selectedIds, setSelectedIds] = useState(selection?.selectedIds || []); @@ -737,6 +749,9 @@ export default function TablePageTemplate({ {/* Action Buttons - Right aligned */}
+ {/* Custom Actions */} + {customActions} + {/* Column Selector */} ({