900 lines
31 KiB
TypeScript
900 lines
31 KiB
TypeScript
/**
|
|
* Keywords Page - Refactored to use TablePageTemplate
|
|
* This demonstrates how to use the config-driven template system
|
|
* while maintaining all existing functionality.
|
|
*/
|
|
|
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
|
import {
|
|
fetchKeywords,
|
|
fetchImages,
|
|
createKeyword,
|
|
updateKeyword,
|
|
deleteKeyword,
|
|
bulkDeleteKeywords,
|
|
bulkUpdateKeywordsStatus,
|
|
fetchPlannerKeywordStats,
|
|
fetchPlannerKeywordFilterOptions,
|
|
FilterOption,
|
|
Keyword,
|
|
KeywordFilters,
|
|
KeywordCreateData,
|
|
fetchClusters,
|
|
Cluster,
|
|
API_BASE_URL,
|
|
autoClusterKeywords,
|
|
} from '../../services/api';
|
|
import { useSiteStore } from '../../store/siteStore';
|
|
import { useSectorStore } from '../../store/sectorStore';
|
|
import { usePageSizeStore } from '../../store/pageSizeStore';
|
|
import PageHeader from '../../components/common/PageHeader';
|
|
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
|
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
|
|
import FormModal from '../../components/common/FormModal';
|
|
import ProgressModal from '../../components/common/ProgressModal';
|
|
import { useProgressModal } from '../../hooks/useProgressModal';
|
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
import { ArrowUpIcon, PlusIcon, ListIcon, DownloadIcon, GroupIcon, BoltIcon } from '../../icons';
|
|
import { useKeywordsImportExport } from '../../config/import-export.config';
|
|
import { createKeywordsPageConfig } from '../../config/pages/keywords.config';
|
|
|
|
export default function Keywords() {
|
|
const toast = useToast();
|
|
const { activeSite } = useSiteStore();
|
|
const { activeSector, loadSectorsForSite } = useSectorStore();
|
|
const { pageSize } = usePageSizeStore();
|
|
|
|
// Data state
|
|
const [keywords, setKeywords] = useState<Keyword[]>([]);
|
|
const [clusters, setClusters] = useState<Cluster[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Total counts for footer widget (not page-filtered)
|
|
const [totalClustered, setTotalClustered] = useState(0);
|
|
const [totalUnmapped, setTotalUnmapped] = useState(0);
|
|
const [totalVolume, setTotalVolume] = useState(0);
|
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
|
|
|
// Dynamic filter options (loaded from backend based on current data)
|
|
const [countryOptions, setCountryOptions] = useState<FilterOption[]>([]);
|
|
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
|
|
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
|
|
|
|
// Filter state - match Keywords.tsx
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('');
|
|
const [clusterFilter, setClusterFilter] = useState('');
|
|
const [countryFilter, setCountryFilter] = useState('');
|
|
const [difficultyFilter, setDifficultyFilter] = useState('');
|
|
const [volumeMin, setVolumeMin] = useState<number | ''>('');
|
|
const [volumeMax, setVolumeMax] = useState<number | ''>('');
|
|
const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false);
|
|
const [tempVolumeMin, setTempVolumeMin] = useState<number | ''>('');
|
|
const [tempVolumeMax, setTempVolumeMax] = useState<number | ''>('');
|
|
const volumeDropdownRef = useRef<HTMLDivElement>(null);
|
|
const volumeButtonRef = useRef<HTMLButtonElement>(null);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
|
|
// Pagination state
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
|
|
// Sorting state (global - not page-specific)
|
|
const [sortBy, setSortBy] = useState<string>('created_at');
|
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
|
const [showContent, setShowContent] = useState(false);
|
|
|
|
// Modal state
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [editingKeyword, setEditingKeyword] = useState<Keyword | null>(null);
|
|
const [formData, setFormData] = useState<KeywordCreateData>({
|
|
keyword: '',
|
|
volume: null,
|
|
difficulty: null,
|
|
country: 'US',
|
|
cluster_id: null,
|
|
status: 'new',
|
|
});
|
|
|
|
// Progress modal for AI functions
|
|
const progressModal = useProgressModal();
|
|
const hasReloadedRef = useRef(false);
|
|
|
|
// Load sectors for active site using sector store
|
|
useEffect(() => {
|
|
if (activeSite) {
|
|
loadSectorsForSite(activeSite.id);
|
|
}
|
|
}, [activeSite, loadSectorsForSite]);
|
|
|
|
// Load clusters for filter dropdown
|
|
useEffect(() => {
|
|
const loadClusters = async () => {
|
|
try {
|
|
const data = await fetchClusters({ ordering: 'name' });
|
|
setClusters(data.results || []);
|
|
} catch (error) {
|
|
console.error('Error fetching clusters:', error);
|
|
}
|
|
};
|
|
loadClusters();
|
|
}, []);
|
|
|
|
// Load dynamic filter options based on current site's data
|
|
const loadFilterOptions = useCallback(async () => {
|
|
if (!activeSite) return;
|
|
|
|
try {
|
|
const options = await fetchPlannerKeywordFilterOptions(activeSite.id);
|
|
setCountryOptions(options.countries || []);
|
|
setStatusOptions(options.statuses || []);
|
|
setClusterOptions(options.clusters || []);
|
|
} catch (error) {
|
|
console.error('Error loading filter options:', error);
|
|
}
|
|
}, [activeSite]);
|
|
|
|
// Load filter options when site changes
|
|
useEffect(() => {
|
|
loadFilterOptions();
|
|
}, [loadFilterOptions]);
|
|
|
|
// Load total metrics for footer widget (site-wide totals, no sector filter)
|
|
const loadTotalMetrics = useCallback(async () => {
|
|
if (!activeSite) return;
|
|
|
|
try {
|
|
// Batch all API calls in parallel for better performance
|
|
const [allRes, mappedRes, newRes, imagesRes, statsRes] = await Promise.all([
|
|
// Get total keywords count (site-wide)
|
|
fetchKeywords({
|
|
page_size: 1,
|
|
site_id: activeSite.id,
|
|
}),
|
|
// Get keywords with status='mapped' (site-wide)
|
|
fetchKeywords({
|
|
page_size: 1,
|
|
site_id: activeSite.id,
|
|
status: 'mapped',
|
|
}),
|
|
// Get keywords with status='new' (site-wide)
|
|
fetchKeywords({
|
|
page_size: 1,
|
|
site_id: activeSite.id,
|
|
status: 'new',
|
|
}),
|
|
// Get actual total images count
|
|
fetchImages({ page_size: 1 }),
|
|
// Get total volume from stats endpoint
|
|
fetchPlannerKeywordStats(activeSite.id),
|
|
]);
|
|
|
|
setTotalCount(allRes.count || 0);
|
|
setTotalClustered(mappedRes.count || 0);
|
|
setTotalUnmapped(newRes.count || 0);
|
|
setTotalImagesCount(imagesRes.count || 0);
|
|
setTotalVolume(statsRes.total_volume || 0);
|
|
} catch (error) {
|
|
console.error('Error loading total metrics:', error);
|
|
}
|
|
}, [activeSite]);
|
|
|
|
// Load total metrics when site/sector changes
|
|
useEffect(() => {
|
|
loadTotalMetrics();
|
|
}, [loadTotalMetrics]);
|
|
|
|
// Load keywords - wrapped in useCallback to prevent infinite loops
|
|
const loadKeywords = useCallback(async () => {
|
|
setLoading(true);
|
|
setShowContent(false); // Reset showContent to show loading state
|
|
try {
|
|
// Build ordering parameter from sort state
|
|
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
|
|
|
|
// Build filters object from individual filter states
|
|
// Only include filters that have actual values (not empty strings)
|
|
// Use activeSector from store for sector filtering
|
|
const filters: KeywordFilters = {
|
|
...(searchTerm && { search: searchTerm }),
|
|
...(statusFilter && { status: statusFilter }),
|
|
...(clusterFilter && { cluster_id: clusterFilter }),
|
|
...(countryFilter && { country: countryFilter }),
|
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
|
page: currentPage,
|
|
page_size: pageSize || 10, // Ensure we always send a page_size
|
|
ordering,
|
|
};
|
|
|
|
// Add difficulty range 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add volume range filters
|
|
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
|
|
filters.volume_min = Number(volumeMin);
|
|
}
|
|
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
|
|
filters.volume_max = Number(volumeMax);
|
|
}
|
|
|
|
const data = await fetchKeywords(filters);
|
|
setKeywords(data.results || []);
|
|
setTotalCount(data.count || 0);
|
|
setTotalPages(Math.ceil((data.count || 0) / pageSize));
|
|
|
|
// Show content after data loads (smooth reveal)
|
|
setTimeout(() => {
|
|
setShowContent(true);
|
|
setLoading(false);
|
|
}, 100);
|
|
} catch (error: any) {
|
|
console.error('Error loading keywords:', error);
|
|
toast.error(`Failed to load keywords: ${error.message}`);
|
|
setShowContent(true);
|
|
setLoading(false);
|
|
}
|
|
}, [currentPage, statusFilter, clusterFilter, countryFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection, searchTerm, activeSite, activeSector, pageSize]);
|
|
|
|
// Listen for site and sector changes and refresh data
|
|
useEffect(() => {
|
|
const handleSiteChange = () => {
|
|
// Reload keywords when site changes (sector store will auto-select first sector)
|
|
loadKeywords();
|
|
// Reload clusters for new site
|
|
const loadClusters = async () => {
|
|
try {
|
|
const data = await fetchClusters({ ordering: 'name' });
|
|
setClusters(data.results || []);
|
|
} catch (error) {
|
|
console.error('Error fetching clusters:', error);
|
|
}
|
|
};
|
|
loadClusters();
|
|
};
|
|
|
|
const handleSectorChange = () => {
|
|
// Reload keywords when sector changes
|
|
loadKeywords();
|
|
};
|
|
|
|
window.addEventListener('siteChanged', handleSiteChange);
|
|
window.addEventListener('sectorChanged', handleSectorChange);
|
|
return () => {
|
|
window.removeEventListener('siteChanged', handleSiteChange);
|
|
window.removeEventListener('sectorChanged', handleSectorChange);
|
|
};
|
|
}, [loadKeywords]);
|
|
|
|
// Handle click outside volume dropdown
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
volumeDropdownRef.current &&
|
|
!volumeDropdownRef.current.contains(event.target as Node) &&
|
|
volumeButtonRef.current &&
|
|
!volumeButtonRef.current.contains(event.target as Node)
|
|
) {
|
|
setIsVolumeDropdownOpen(false);
|
|
setTempVolumeMin(volumeMin);
|
|
setTempVolumeMax(volumeMax);
|
|
}
|
|
};
|
|
|
|
if (isVolumeDropdownOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}
|
|
}, [isVolumeDropdownOpen, volumeMin, volumeMax]);
|
|
|
|
// Load data on mount and when filters change (excluding search - handled separately)
|
|
useEffect(() => {
|
|
loadKeywords();
|
|
}, [loadKeywords]);
|
|
|
|
// Debounced search - reset to page 1 when search term changes
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
// Always reset to page 1 when search changes
|
|
// The main useEffect will handle reloading when currentPage changes
|
|
setCurrentPage(1);
|
|
}, 500);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [searchTerm]); // Only depend on searchTerm
|
|
|
|
// Handle pageSize changes - reload data when pageSize changes
|
|
// Note: TablePageTemplate already calls onPageChange(1), but we need to ensure reload happens
|
|
useEffect(() => {
|
|
// When pageSize changes:
|
|
// 1. Reset to page 1 (TablePageTemplate does this, but we ensure it)
|
|
// 2. loadKeywords will be recreated because pageSize is in its dependency array
|
|
// 3. The useEffect that depends on loadKeywords will fire and reload data
|
|
// But we need to ensure reload happens even if currentPage is already 1
|
|
const wasOnPage1 = currentPage === 1;
|
|
setCurrentPage(1);
|
|
|
|
// If we were already on page 1, explicitly trigger reload
|
|
// Otherwise, the currentPage change will trigger reload via loadKeywords dependency
|
|
if (wasOnPage1) {
|
|
// Use a small timeout to ensure state has updated
|
|
setTimeout(() => {
|
|
loadKeywords();
|
|
}, 0);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [pageSize]); // Only depend on pageSize - loadKeywords will be recreated when pageSize changes
|
|
|
|
|
|
// Handle sorting (global)
|
|
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
|
setSortBy(field || 'created_at');
|
|
setSortDirection(direction);
|
|
setCurrentPage(1); // Reset to first page when sorting changes
|
|
};
|
|
|
|
// Import/Export handlers
|
|
const { handleExport, handleImportClick, ImportModal } = useKeywordsImportExport(
|
|
() => {
|
|
toast.success('Import successful', 'Keywords imported successfully.');
|
|
loadKeywords();
|
|
},
|
|
(error) => {
|
|
toast.error('Import failed', error.message);
|
|
},
|
|
// Pass active site_id and active sector_id for import
|
|
activeSite && activeSector
|
|
? { site_id: activeSite.id, sector_id: activeSector.id }
|
|
: undefined
|
|
);
|
|
|
|
// Handle bulk actions (delete, export, update_status are now handled by TablePageTemplate)
|
|
// This is only for actions that don't have modals (like auto_cluster)
|
|
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
|
if (action === 'auto_cluster') {
|
|
if (ids.length === 0) {
|
|
toast.error('Please select at least one keyword to cluster');
|
|
return;
|
|
}
|
|
if (ids.length > 50) {
|
|
toast.error('Maximum 50 keywords allowed for clustering');
|
|
return;
|
|
}
|
|
|
|
const numIds = ids.map(id => parseInt(id));
|
|
const sectorId = activeSector?.id;
|
|
const selectedKeywords = keywords.filter(k => numIds.includes(k.id));
|
|
|
|
// Validate single sector - keywords must all be from the same sector
|
|
const uniqueSectors = new Set(selectedKeywords.map(k => k.sector_id).filter(Boolean));
|
|
if (uniqueSectors.size > 1) {
|
|
toast.error(`Selected keywords span ${uniqueSectors.size} different sectors. Please select keywords from a single sector only.`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await autoClusterKeywords(numIds, sectorId);
|
|
|
|
// Check if result has success field - if false, it's an error response
|
|
if (result && result.success === false) {
|
|
// Error response from API
|
|
const errorMsg = result.error || 'Failed to cluster keywords';
|
|
toast.error(errorMsg);
|
|
return;
|
|
}
|
|
|
|
if (result && result.success) {
|
|
if (result.task_id) {
|
|
// Async task - open progress modal
|
|
hasReloadedRef.current = false;
|
|
progressModal.openModal(result.task_id, 'Auto-Clustering Keywords', 'ai-auto-cluster-01');
|
|
// Don't show toast - progress modal will show status
|
|
} else {
|
|
// Synchronous completion
|
|
toast.success(`Clustering complete: ${result.clusters_created || 0} clusters created, ${result.keywords_updated || 0} keywords updated`);
|
|
if (!hasReloadedRef.current) {
|
|
hasReloadedRef.current = true;
|
|
loadKeywords();
|
|
}
|
|
}
|
|
} else {
|
|
// Unexpected response format - show error
|
|
const errorMsg = result?.error || 'Unexpected response format';
|
|
toast.error(errorMsg);
|
|
}
|
|
} catch (error: any) {
|
|
// API error (network error, parse error, etc.)
|
|
let errorMsg = 'Failed to cluster keywords';
|
|
if (error.message) {
|
|
// Extract clean error message from API error format
|
|
errorMsg = error.message.replace(/^API Error \(\d+\): [^-]+ - /, '').trim();
|
|
if (!errorMsg || errorMsg === error.message) {
|
|
errorMsg = error.message;
|
|
}
|
|
}
|
|
toast.error(errorMsg);
|
|
}
|
|
} else {
|
|
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
|
}
|
|
}, [toast, activeSector, loadKeywords, progressModal, keywords]);
|
|
|
|
// Quick auto-cluster unclustered keywords (for Next Step button)
|
|
const handleAutoCluster = useCallback(async () => {
|
|
const unclusteredIds = keywords.filter(k => !k.cluster_id).map(k => k.id);
|
|
if (unclusteredIds.length === 0) {
|
|
toast.info('All keywords are already clustered');
|
|
return;
|
|
}
|
|
// Limit to 50 keywords
|
|
const idsToCluster = unclusteredIds.slice(0, 50);
|
|
await handleBulkAction('auto_cluster', idsToCluster.map(String));
|
|
}, [keywords, handleBulkAction, toast]);
|
|
|
|
// Reset reload flag when modal closes or opens
|
|
useEffect(() => {
|
|
if (!progressModal.isOpen) {
|
|
hasReloadedRef.current = false; // Reset reload flag when modal closes
|
|
} else {
|
|
// Reset reload flag when modal opens for a new task
|
|
hasReloadedRef.current = false;
|
|
}
|
|
}, [progressModal.isOpen, progressModal.taskId]);
|
|
|
|
const resetForm = useCallback(() => {
|
|
setFormData({
|
|
keyword: '',
|
|
volume: null,
|
|
difficulty: null,
|
|
country: 'US',
|
|
cluster_id: null,
|
|
status: 'new',
|
|
});
|
|
setIsEditMode(false);
|
|
setEditingKeyword(null);
|
|
}, []);
|
|
|
|
|
|
// Bulk export handler for selected items (used by TablePageTemplate modal)
|
|
const handleBulkExport = useCallback(async (ids: string[]) => {
|
|
try {
|
|
// Ensure we have valid IDs
|
|
if (!ids || ids.length === 0) {
|
|
throw new Error('No records selected for export');
|
|
}
|
|
|
|
// For bulk export, ONLY export selected IDs - ignore ALL other filters
|
|
// Build URL directly to ensure only 'ids' parameter is sent
|
|
const idsParam = ids.join(',');
|
|
const exportUrl = `${API_BASE_URL}/v1/planner/keywords/export/?ids=${encodeURIComponent(idsParam)}`;
|
|
|
|
const response = await fetch(exportUrl, {
|
|
method: 'GET',
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Export failed: ${response.statusText} - ${errorText}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const downloadUrl = window.URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = downloadUrl;
|
|
link.download = 'keywords.csv';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(downloadUrl);
|
|
} catch (error: any) {
|
|
throw error; // Let TablePageTemplate handle toast
|
|
}
|
|
}, []);
|
|
|
|
// Bulk status update handler (used by TablePageTemplate modal)
|
|
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
|
|
try {
|
|
const numIds = ids.map(id => parseInt(id));
|
|
await bulkUpdateKeywordsStatus(numIds, status);
|
|
await loadKeywords(); // Reload data after status update
|
|
} catch (error: any) {
|
|
throw error; // Let TablePageTemplate handle toast
|
|
}
|
|
}, [loadKeywords]);
|
|
|
|
// Create page config using factory function - all config comes from keywords.config.tsx
|
|
const pageConfig = useMemo(() => {
|
|
return createKeywordsPageConfig({
|
|
clusters,
|
|
activeSector,
|
|
formData,
|
|
setFormData,
|
|
// Filter state handlers
|
|
searchTerm,
|
|
setSearchTerm,
|
|
statusFilter,
|
|
setStatusFilter,
|
|
countryFilter,
|
|
setCountryFilter,
|
|
difficultyFilter,
|
|
setDifficultyFilter,
|
|
clusterFilter,
|
|
setClusterFilter,
|
|
volumeMin,
|
|
volumeMax,
|
|
setVolumeMin,
|
|
setVolumeMax,
|
|
isVolumeDropdownOpen,
|
|
setIsVolumeDropdownOpen,
|
|
tempVolumeMin,
|
|
tempVolumeMax,
|
|
setTempVolumeMin,
|
|
setTempVolumeMax,
|
|
volumeButtonRef,
|
|
volumeDropdownRef,
|
|
setCurrentPage,
|
|
loadKeywords,
|
|
// Dynamic filter options
|
|
countryOptions,
|
|
statusOptions,
|
|
clusterOptions,
|
|
});
|
|
}, [
|
|
clusters,
|
|
activeSector,
|
|
formData,
|
|
searchTerm,
|
|
statusFilter,
|
|
countryFilter,
|
|
difficultyFilter,
|
|
clusterFilter,
|
|
volumeMin,
|
|
volumeMax,
|
|
isVolumeDropdownOpen,
|
|
tempVolumeMin,
|
|
tempVolumeMax,
|
|
loadKeywords,
|
|
activeSite,
|
|
countryOptions,
|
|
statusOptions,
|
|
clusterOptions,
|
|
]);
|
|
|
|
// Calculate header metrics - use totalClustered/totalUnmapped from API calls (not page data)
|
|
// This ensures metrics show correct totals across all pages, not just current page
|
|
const headerMetrics = useMemo(() => {
|
|
if (!pageConfig?.headerMetrics) return [];
|
|
|
|
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
|
return pageConfig.headerMetrics.map((metric) => {
|
|
let value: number;
|
|
|
|
switch (metric.label) {
|
|
case 'Keywords':
|
|
value = totalCount || 0;
|
|
break;
|
|
case 'Clustered':
|
|
// Use totalClustered from loadTotalMetrics() instead of filtering page data
|
|
value = totalClustered;
|
|
break;
|
|
case 'Unmapped':
|
|
// Use totalUnmapped from loadTotalMetrics() instead of filtering page data
|
|
value = totalUnmapped;
|
|
break;
|
|
case 'Volume':
|
|
// Use totalVolume from loadTotalMetrics() (if implemented) or keep original
|
|
value = totalVolume || keywords.reduce((sum: number, k) => sum + (k.volume || 0), 0);
|
|
break;
|
|
default:
|
|
value = metric.calculate({ keywords, totalCount, clusters });
|
|
}
|
|
|
|
return {
|
|
label: metric.label,
|
|
value,
|
|
accentColor: metric.accentColor,
|
|
tooltip: (metric as any).tooltip,
|
|
};
|
|
});
|
|
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters, totalClustered, totalUnmapped, totalVolume]);
|
|
|
|
// Calculate workflow insights based on UX doc principles
|
|
const workflowStats = useMemo(() => {
|
|
const clusteredCount = keywords.filter(k => k.cluster_id).length;
|
|
const unclusteredCount = totalCount - clusteredCount;
|
|
const pipelineReadiness = totalCount > 0 ? Math.round((clusteredCount / totalCount) * 100) : 0;
|
|
|
|
return {
|
|
total: totalCount,
|
|
clustered: clusteredCount,
|
|
unclustered: unclusteredCount,
|
|
readiness: pipelineReadiness,
|
|
};
|
|
}, [keywords, totalCount]);
|
|
|
|
// Handle create/edit
|
|
const handleSave = async () => {
|
|
try {
|
|
if (!activeSite) {
|
|
toast.error('Please select an active site first');
|
|
return;
|
|
}
|
|
|
|
if (isEditMode && editingKeyword) {
|
|
await updateKeyword(editingKeyword.id, formData);
|
|
toast.success('Keyword updated successfully');
|
|
} else {
|
|
// For new keywords, add site_id and sector_id
|
|
// Use active sector from store
|
|
if (!activeSector) {
|
|
toast.error('Please select a sector for this site first');
|
|
return;
|
|
}
|
|
|
|
if (!formData.keyword?.trim()) {
|
|
toast.error('Please enter a keyword');
|
|
return;
|
|
}
|
|
|
|
if (formData.volume === null || formData.volume === undefined) {
|
|
toast.error('Please enter search volume');
|
|
return;
|
|
}
|
|
|
|
if (formData.difficulty === null || formData.difficulty === undefined) {
|
|
toast.error('Please enter difficulty score');
|
|
return;
|
|
}
|
|
|
|
const sectorId = activeSector.id;
|
|
|
|
const keywordData: any = {
|
|
...formData,
|
|
site_id: activeSite.id,
|
|
sector_id: sectorId,
|
|
};
|
|
|
|
console.log('Creating keyword with data:', keywordData);
|
|
await createKeyword(keywordData);
|
|
toast.success('Keyword created successfully');
|
|
}
|
|
setIsModalOpen(false);
|
|
resetForm();
|
|
loadKeywords();
|
|
} catch (error: any) {
|
|
toast.error(error.message || 'Unable to save keyword. Please try again.');
|
|
}
|
|
};
|
|
|
|
// Handle edit - populate form with existing keyword data
|
|
const handleEdit = useCallback((keyword: Keyword) => {
|
|
setEditingKeyword(keyword);
|
|
setIsEditMode(true);
|
|
setFormData({
|
|
keyword: keyword.keyword,
|
|
volume: keyword.volume,
|
|
difficulty: keyword.difficulty,
|
|
country: keyword.country,
|
|
cluster_id: keyword.cluster_id,
|
|
status: keyword.status,
|
|
});
|
|
setIsModalOpen(true);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Keywords"
|
|
badge={{ icon: <ListIcon />, color: 'green' }}
|
|
parent="Planner"
|
|
/>
|
|
<TablePageTemplate
|
|
columns={pageConfig.columns}
|
|
data={keywords}
|
|
loading={loading}
|
|
showContent={showContent}
|
|
checkboxColumnWidth="40px"
|
|
filters={pageConfig.filters}
|
|
filterValues={{
|
|
search: searchTerm,
|
|
status: statusFilter,
|
|
country: countryFilter,
|
|
difficulty: difficultyFilter,
|
|
cluster_id: clusterFilter,
|
|
volumeMin: volumeMin,
|
|
volumeMax: volumeMax,
|
|
}}
|
|
primaryAction={{
|
|
label: 'Auto-Cluster',
|
|
icon: <BoltIcon className="w-4 h-4" />,
|
|
onClick: () => handleBulkAction('auto_cluster', selectedIds),
|
|
variant: 'success',
|
|
}}
|
|
onFilterChange={(key, value) => {
|
|
// Normalize value to string, preserving empty strings
|
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
|
|
|
// Map filter keys to state setters
|
|
if (key === 'search') {
|
|
setSearchTerm(stringValue);
|
|
} else if (key === 'status') {
|
|
setStatusFilter(stringValue);
|
|
setCurrentPage(1);
|
|
} else if (key === 'country') {
|
|
setCountryFilter(stringValue);
|
|
setCurrentPage(1);
|
|
} else if (key === 'difficulty') {
|
|
setDifficultyFilter(stringValue);
|
|
setCurrentPage(1);
|
|
} else if (key === 'cluster_id') {
|
|
setClusterFilter(stringValue);
|
|
setCurrentPage(1);
|
|
}
|
|
// Note: volume filter is handled by custom render, cluster options updated dynamically
|
|
}}
|
|
onEdit={handleEdit}
|
|
onCreate={() => {
|
|
resetForm();
|
|
setIsModalOpen(true);
|
|
}}
|
|
createLabel="Add Keyword"
|
|
onCreateIcon={<PlusIcon />}
|
|
onDelete={async (id: number) => {
|
|
await deleteKeyword(id);
|
|
loadKeywords();
|
|
}}
|
|
onBulkDelete={async (ids: number[]) => {
|
|
const result = await bulkDeleteKeywords(ids);
|
|
// Clear selection first
|
|
setSelectedIds([]);
|
|
// Reset to page 1 if we deleted all items on current page
|
|
if (currentPage > 1 && keywords.length <= ids.length) {
|
|
setCurrentPage(1);
|
|
}
|
|
// Always reload data to refresh the table
|
|
await loadKeywords();
|
|
return result;
|
|
}}
|
|
onBulkExport={handleBulkExport}
|
|
onBulkUpdateStatus={handleBulkUpdateStatus}
|
|
onBulkAction={handleBulkAction}
|
|
getItemDisplayName={(row: Keyword) => row.keyword}
|
|
onExport={async () => {
|
|
try {
|
|
const filterValues = {
|
|
search: searchTerm,
|
|
status: statusFilter,
|
|
cluster_id: clusterFilter,
|
|
country: countryFilter,
|
|
difficulty: difficultyFilter,
|
|
};
|
|
await handleExport('csv', filterValues);
|
|
toast.success('Export successful', 'Keywords exported successfully.');
|
|
} catch (error: any) {
|
|
toast.error('Export failed', error.message);
|
|
}
|
|
}}
|
|
onExportIcon={<DownloadIcon />}
|
|
onImport={handleImportClick}
|
|
onImportIcon={<ArrowUpIcon />}
|
|
selectionLabel="keyword"
|
|
pagination={{
|
|
currentPage,
|
|
totalPages,
|
|
totalCount,
|
|
onPageChange: (page: number) => {
|
|
setCurrentPage(page);
|
|
},
|
|
}}
|
|
selection={{
|
|
selectedIds,
|
|
onSelectionChange: setSelectedIds,
|
|
}}
|
|
sorting={{
|
|
sortBy,
|
|
sortDirection,
|
|
onSort: handleSort,
|
|
}}
|
|
headerMetrics={headerMetrics}
|
|
onFilterReset={() => {
|
|
setSearchTerm('');
|
|
setStatusFilter('');
|
|
setClusterFilter('');
|
|
setCountryFilter('');
|
|
setDifficultyFilter('');
|
|
setVolumeMin('');
|
|
setVolumeMax('');
|
|
setTempVolumeMin('');
|
|
setTempVolumeMax('');
|
|
setIsVolumeDropdownOpen(false);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>
|
|
|
|
{/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */}
|
|
<StandardThreeWidgetFooter
|
|
submoduleColor="blue"
|
|
pageProgress={{
|
|
title: 'Page Progress',
|
|
submoduleColor: 'blue',
|
|
metrics: [
|
|
{ label: 'Keywords', value: totalCount },
|
|
{ label: 'Clustered', value: totalClustered, percentage: `${totalCount > 0 ? Math.round((totalClustered / totalCount) * 100) : 0}%` },
|
|
{ label: 'Unmapped', value: totalUnmapped },
|
|
{ label: 'Volume', value: totalVolume > 0 ? `${(totalVolume / 1000).toFixed(1)}K` : '-' },
|
|
],
|
|
progress: {
|
|
value: totalCount > 0 ? Math.round((totalClustered / totalCount) * 100) : 0,
|
|
label: 'Clustered',
|
|
color: 'blue',
|
|
},
|
|
hint: totalUnmapped > 0
|
|
? `${totalUnmapped} keywords ready to cluster`
|
|
: 'All keywords clustered!',
|
|
statusInsight: totalUnmapped > 0
|
|
? `Select unmapped keywords and run clustering to group them into topics.`
|
|
: totalClustered > 0
|
|
? `Keywords are clustered. Go to Clusters to generate content ideas.`
|
|
: `Add keywords to begin. Import from CSV or add manually.`,
|
|
}}
|
|
module="planner"
|
|
showCredits={true}
|
|
analyticsHref="/account/usage"
|
|
/>
|
|
|
|
{/* Create/Edit Modal */}
|
|
<FormModal
|
|
key={`keyword-form-${isEditMode ? editingKeyword?.id : 'new'}`}
|
|
isOpen={isModalOpen}
|
|
onClose={() => {
|
|
setIsModalOpen(false);
|
|
resetForm();
|
|
}}
|
|
onSubmit={handleSave}
|
|
title={isEditMode ? 'Edit Keyword' : 'Add Keyword'}
|
|
submitLabel={isEditMode ? 'Update' : 'Create'}
|
|
fields={pageConfig.formFields(clusters)}
|
|
/>
|
|
|
|
{/* Import Modal */}
|
|
<ImportModal />
|
|
|
|
{/* Progress Modal for AI Functions */}
|
|
<ProgressModal
|
|
isOpen={progressModal.isOpen}
|
|
title={progressModal.title}
|
|
percentage={progressModal.progress.percentage}
|
|
status={progressModal.progress.status}
|
|
message={progressModal.progress.message}
|
|
details={progressModal.progress.details}
|
|
taskId={progressModal.taskId || undefined}
|
|
functionId={progressModal.functionId}
|
|
onClose={() => {
|
|
progressModal.closeModal();
|
|
// Reload once when modal closes if task was completed
|
|
if (progressModal.progress.status === 'completed' && !hasReloadedRef.current) {
|
|
hasReloadedRef.current = true;
|
|
loadKeywords();
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|