/** * Clusters Page - Refactored to use TablePageTemplate * Consistent with Keywords page layout, structure and design */ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchClusters, 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 ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter'; export default function Clusters() { const toast = useToast(); const { activeSector } = useSectorStore(); const { pageSize } = usePageSizeStore(); // Data state const [clusters, setClusters] = useState([]); const [loading, setLoading] = useState(true); // 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 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 useEffect(() => { const timer = setTimeout(() => { if (currentPage === 1) { loadClusters(); } else { setCurrentPage(1); } }, 500); return () => clearTimeout(timer); }, [searchTerm, currentPage, loadClusters]); // 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, }); }, [ activeSector, formData, searchTerm, statusFilter, difficultyFilter, volumeMin, volumeMax, isVolumeDropdownOpen, tempVolumeMin, tempVolumeMax, loadClusters, ]); // Calculate header metrics const headerMetrics = useMemo(() => { if (!pageConfig?.headerMetrics) return []; return pageConfig.headerMetrics.map((metric) => ({ label: metric.label, value: metric.calculate({ clusters, totalCount }), accentColor: metric.accentColor, tooltip: (metric as any).tooltip, })); }, [pageConfig?.headerMetrics, clusters, totalCount]); 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' }} breadcrumb="Planner" actions={ Generate Ideas } /> { 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); }} /> {/* Module Metrics Footer - Pipeline Style with Cross-Module Links */} sum + (c.keywords_count || 0), 0).toLocaleString(), subtitle: `in ${totalCount} clusters`, icon: , accentColor: 'blue', href: '/planner/keywords', }, { title: 'Content Ideas', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0).toLocaleString(), subtitle: `across ${clusters.filter(c => (c.ideas_count || 0) > 0).length} clusters`, icon: , accentColor: 'green', href: '/planner/ideas', }, { title: 'Ready to Write', value: clusters.filter(c => (c.ideas_count || 0) > 0 && c.status === 'active').length.toLocaleString(), subtitle: 'clusters with approved ideas', icon: , accentColor: 'purple', }, ]} progress={{ label: 'Idea Generation Pipeline: Clusters with content ideas generated (ready for downstream content creation)', value: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0, color: 'purple', }} /> {/* 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()} /> ); }