/** * Clusters Page - Refactored to use TablePageTemplate * Consistent with Keywords page layout, structure and design */ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchClusters, fetchClustersSummary, fetchImages, createCluster, updateCluster, deleteCluster, bulkDeleteClusters, bulkUpdateClustersStatus, autoGenerateIdeas, Cluster, ClusterFilters, ClusterCreateData, } from '../../services/api'; 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 { GroupIcon, PlusIcon, DownloadIcon, ListIcon, BoltIcon } from '../../icons'; import { createClustersPageConfig } from '../../config/pages/clusters.config'; import { useSectorStore } from '../../store/sectorStore'; import { usePageSizeStore } from '../../store/pageSizeStore'; import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty'; import PageHeader from '../../components/common/PageHeader'; import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter'; export default function Clusters() { const toast = useToast(); const { activeSector } = useSectorStore(); const { pageSize } = usePageSizeStore(); // Data state const [clusters, setClusters] = useState([]); const [loading, setLoading] = useState(true); // Total counts for footer widget (not page-filtered) const [totalWithIdeas, setTotalWithIdeas] = useState(0); const [totalReady, setTotalReady] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0); const [totalVolume, setTotalVolume] = useState(0); const [totalKeywords, setTotalKeywords] = useState(0); // Filter state const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [difficultyFilter, setDifficultyFilter] = useState(''); const [volumeMin, setVolumeMin] = useState(''); const [volumeMax, setVolumeMax] = useState(''); const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false); const [tempVolumeMin, setTempVolumeMin] = useState(''); const [tempVolumeMax, setTempVolumeMax] = useState(''); const volumeDropdownRef = useRef(null); const volumeButtonRef = useRef(null); const [selectedIds, setSelectedIds] = useState([]); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [totalCount, setTotalCount] = useState(0); // Sorting state const [sortBy, setSortBy] = useState('name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [showContent, setShowContent] = useState(false); // Modal state const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const [editingCluster, setEditingCluster] = useState(null); const [formData, setFormData] = useState({ name: '', description: '', status: 'active', }); // Progress modal for AI functions const progressModal = useProgressModal(); const hasReloadedRef = useRef(false); // Load total metrics for footer widget (not affected by pagination) const loadTotalMetrics = useCallback(async () => { try { // Fetch summary metrics in parallel with status counts const [summaryRes, mappedRes, newRes, imagesRes] = await Promise.all([ fetchClustersSummary(activeSector?.id), fetchClusters({ page_size: 1, ...(activeSector?.id && { sector_id: activeSector.id }), status: 'mapped', }), fetchClusters({ page_size: 1, ...(activeSector?.id && { sector_id: activeSector.id }), status: 'new', }), fetchImages({ page_size: 1 }), ]); // Set summary metrics setTotalVolume(summaryRes.total_volume || 0); setTotalKeywords(summaryRes.total_keywords || 0); // Set status counts setTotalWithIdeas(mappedRes.count || 0); setTotalReady(newRes.count || 0); // Set images count setTotalImagesCount(imagesRes.count || 0); } catch (error) { console.error('Error loading total metrics:', error); } }, [activeSector]); // Load total metrics when sector changes useEffect(() => { loadTotalMetrics(); }, [loadTotalMetrics]); // Load clusters - wrapped in useCallback to prevent infinite loops const loadClusters = useCallback(async () => { setLoading(true); setShowContent(false); try { const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : 'name'; const filters: ClusterFilters = { ...(searchTerm && { search: searchTerm }), ...(statusFilter && { status: statusFilter }), ...(activeSector?.id && { sector_id: activeSector.id }), page: currentPage, page_size: pageSize, 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 fetchClusters(filters); setClusters(data.results || []); setTotalCount(data.count || 0); setTotalPages(Math.ceil((data.count || 0) / pageSize)); setTimeout(() => { setShowContent(true); setLoading(false); }, 100); } catch (error: any) { console.error('Error loading clusters:', error); toast.error(`Failed to load clusters: ${error.message}`); setShowContent(true); setLoading(false); } }, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, difficultyFilter, volumeMin, volumeMax, activeSector, pageSize]); // Load data on mount and when filters change useEffect(() => { loadClusters(); }, [loadClusters]); // Listen for site and sector changes and refresh data useEffect(() => { const handleSiteChange = () => { loadClusters(); }; const handleSectorChange = () => { loadClusters(); }; window.addEventListener('siteChanged', handleSiteChange); window.addEventListener('sectorChanged', handleSectorChange); return () => { window.removeEventListener('siteChanged', handleSiteChange); window.removeEventListener('sectorChanged', handleSectorChange); }; }, [loadClusters]); // Debounced search - reset to page 1 when search term changes // Only depend on searchTerm to avoid pagination reset on page navigation 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]); // Reset to page 1 when pageSize changes useEffect(() => { setCurrentPage(1); }, [pageSize]); // Handle sorting const handleSort = (field: string, direction: 'asc' | 'desc') => { setSortBy(field || 'name'); setSortDirection(direction); setCurrentPage(1); }; // Bulk status update handler const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => { try { const numIds = ids.map(id => parseInt(id)); await bulkUpdateClustersStatus(numIds, status); await loadClusters(); } catch (error: any) { throw error; } }, [loadClusters]); // Bulk export handler const handleBulkExport = useCallback(async (ids: string[]) => { try { if (!ids || ids.length === 0) { throw new Error('No records selected for export'); } // TODO: Implement bulk export endpoint toast.info('Export functionality coming soon'); } catch (error: any) { throw error; } }, []); // Row action handler const handleRowAction = useCallback(async (action: string, row: Cluster) => { if (action === 'generate_ideas') { try { const result = await autoGenerateIdeas([row.id]); if (result.success && result.task_id) { // Async task - show progress modal progressModal.openModal(result.task_id, 'Generating Ideas', 'ai-generate-ideas-01-desktop'); } else if (result.success && result.ideas_created) { // Synchronous completion toast.success(result.message || 'Ideas generated successfully'); await loadClusters(); } else { toast.error(result.error || 'Failed to generate ideas'); } } catch (error: any) { toast.error(`Failed to generate ideas: ${error.message}`); } } }, [toast, progressModal, loadClusters]); // Bulk action handler const handleBulkAction = useCallback(async (action: string, ids: string[]) => { if (action === 'auto_generate_ideas') { if (ids.length === 0) { toast.error('Please select at least one cluster to generate ideas'); return; } if (ids.length > 5) { toast.error('Maximum 5 clusters allowed for idea generation'); return; } try { const numIds = ids.map(id => parseInt(id)); const result = await autoGenerateIdeas(numIds); // 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 generate ideas'; 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, 'Generating Content Ideas', 'ai-generate-ideas-01-desktop'); // Don't show toast - progress modal will show status } else { // Synchronous completion toast.success(`Ideas generation complete: ${result.ideas_created || 0} ideas created`); if (!hasReloadedRef.current) { hasReloadedRef.current = true; loadClusters(); } } } 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 generate ideas'; 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, loadClusters, progressModal]); // Close volume dropdown when clicking outside 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); } }; if (isVolumeDropdownOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isVolumeDropdownOpen]); // Create page config const pageConfig = useMemo(() => { return createClustersPageConfig({ activeSector, formData, setFormData, searchTerm, setSearchTerm, statusFilter, setStatusFilter, difficultyFilter, setDifficultyFilter, volumeMin, volumeMax, setVolumeMin, setVolumeMax, isVolumeDropdownOpen, setIsVolumeDropdownOpen, tempVolumeMin, tempVolumeMax, setTempVolumeMin, setTempVolumeMax, volumeButtonRef, volumeDropdownRef, setCurrentPage, loadClusters, onGenerateIdeas: (clusterId: number) => handleRowAction('generate_ideas', { id: clusterId } as Cluster), }); }, [ activeSector, formData, searchTerm, statusFilter, difficultyFilter, volumeMin, volumeMax, isVolumeDropdownOpen, tempVolumeMin, tempVolumeMax, loadClusters, handleRowAction, ]); // Calculate header metrics - use totalWithIdeas/totalReady 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 'Clusters': value = totalCount || 0; break; case 'New': // Use totalReady from loadTotalMetrics() (clusters without ideas) value = totalReady; break; case 'Keywords': // Use totalKeywords from summary endpoint (aggregate across all clusters) value = totalKeywords; break; case 'Volume': // Use totalVolume from summary endpoint (aggregate across all clusters) value = totalVolume; break; default: value = metric.calculate({ clusters, totalCount }); } return { label: metric.label, value, accentColor: metric.accentColor, tooltip: (metric as any).tooltip, }; }); }, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas, totalVolume, totalKeywords]); const resetForm = useCallback(() => { setFormData({ name: '', description: '', status: 'active', }); setIsEditMode(false); setEditingCluster(null); }, []); // Handle create/edit const handleSave = async () => { try { if (isEditMode && editingCluster) { await updateCluster(editingCluster.id, formData); toast.success('Cluster updated successfully'); } else { await createCluster(formData); toast.success('Cluster created successfully'); } setIsModalOpen(false); resetForm(); loadClusters(); } catch (error: any) { toast.error(error.message || 'Unable to save cluster. Please try again.'); } }; return ( <> , color: 'purple' }} parent="Planner" /> { const stringValue = value === null || value === undefined ? '' : String(value); if (key === 'search') { setSearchTerm(stringValue); } else if (key === 'status') { setStatusFilter(stringValue); } else if (key === 'difficulty') { setDifficultyFilter(stringValue); } setCurrentPage(1); }} onEdit={(row) => { setEditingCluster(row); setFormData({ name: row.name || '', description: row.description || '', status: row.status || 'active', }); setIsEditMode(true); setIsModalOpen(true); }} onCreate={() => { resetForm(); setIsModalOpen(true); }} createLabel="Create Cluster" onCreateIcon={} onDelete={async (id: number) => { await deleteCluster(id); loadClusters(); }} onBulkDelete={async (ids: number[]) => { const result = await bulkDeleteClusters(ids); // Clear selection first setSelectedIds([]); // Reset to page 1 if we deleted all items on current page if (currentPage > 1 && clusters.length <= ids.length) { setCurrentPage(1); } // Always reload data to refresh the table await loadClusters(); return result; }} onBulkExport={handleBulkExport} onBulkUpdateStatus={handleBulkUpdateStatus} onBulkAction={handleBulkAction} onRowAction={handleRowAction} getItemDisplayName={(row: Cluster) => row.name} onExport={async () => { toast.info('Export functionality coming soon'); }} onExportIcon={} selectionLabel="cluster" pagination={{ currentPage, totalPages, totalCount, onPageChange: setCurrentPage, }} selection={{ selectedIds, onSelectionChange: setSelectedIds, }} sorting={{ sortBy, sortDirection, onSort: handleSort, }} headerMetrics={headerMetrics} onFilterReset={() => { setSearchTerm(''); setStatusFilter(''); setDifficultyFilter(''); setVolumeMin(''); setVolumeMax(''); setCurrentPage(1); }} /> {/* Three Widget Footer - Section 3 Layout */} 0 ? Math.round((totalWithIdeas / totalCount) * 100) : 0}%` }, { label: 'Keywords', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0) }, { label: 'Ready', value: totalReady }, ], progress: { value: totalCount > 0 ? Math.round((totalWithIdeas / totalCount) * 100) : 0, label: 'Have Ideas', color: 'green', }, hint: totalReady > 0 ? `${totalReady} clusters ready for idea generation` : 'All clusters have ideas!', }} moduleStats={{ title: 'Planner Module', pipeline: [ { fromLabel: 'Keywords', fromValue: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), fromHref: '/planner/keywords', actionLabel: 'Auto Cluster', toLabel: 'Clusters', toValue: totalCount, progress: 100, color: 'blue', }, { fromLabel: 'Clusters', fromValue: totalCount, actionLabel: 'Generate Ideas', toLabel: 'Ideas', toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), toHref: '/planner/ideas', progress: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0, color: 'green', }, { fromLabel: 'Ideas', fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), fromHref: '/planner/ideas', actionLabel: 'Create Tasks', toLabel: 'Tasks', toValue: 0, toHref: '/writer/tasks', progress: 0, color: 'amber', }, ], links: [ { label: 'Keywords', href: '/planner/keywords' }, { label: 'Clusters', href: '/planner/clusters' }, { label: 'Ideas', href: '/planner/ideas' }, ], }} completion={{ title: 'Workflow Completion', plannerItems: [ { label: 'Keywords Clustered', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' }, { label: 'Clusters Created', value: totalCount, color: 'green' }, { label: 'Ideas Generated', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' }, ], writerItems: [ { label: 'Content Generated', value: 0, color: 'blue' }, { label: 'Images Created', value: totalImagesCount, color: 'purple' }, { label: 'Published', value: 0, color: 'green' }, ], analyticsHref: '/account/usage', }} /> {/* Progress Modal for AI Functions */} { progressModal.closeModal(); // Reload once when modal closes if task was completed if (progressModal.progress.status === 'completed' && !hasReloadedRef.current) { hasReloadedRef.current = true; loadClusters(); } }} /> {/* Create/Edit Modal */} { setIsModalOpen(false); resetForm(); }} onSubmit={handleSave} title={isEditMode ? 'Edit Cluster' : 'Add Cluster'} submitLabel={isEditMode ? 'Update' : 'Create'} fields={pageConfig.formFields()} /> ); }