/** * Ideas Page - Built with TablePageTemplate * Consistent with Keywords page layout, structure and design */ import { useState, useEffect, useMemo, useCallback } from 'react'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchContentIdeas, fetchImages, createContentIdea, updateContentIdea, deleteContentIdea, bulkDeleteContentIdeas, bulkUpdateContentIdeasStatus, bulkQueueIdeasToWriter, ContentIdea, ContentIdeasFilters, ContentIdeaCreateData, fetchClusters, Cluster, fetchPlannerIdeasFilterOptions, FilterOption, API_BASE_URL, } 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 { BoltIcon, PlusIcon, DownloadIcon, ListIcon, GroupIcon, ArrowRightIcon } from '../../icons'; import { LightBulbIcon } from '@heroicons/react/24/outline'; import { createIdeasPageConfig } from '../../config/pages/ideas.config'; import { useSectorStore } from '../../store/sectorStore'; import { useSiteStore } from '../../store/siteStore'; import { usePageSizeStore } from '../../store/pageSizeStore'; import { useAuthStore } from '../../store/authStore'; import PageHeader from '../../components/common/PageHeader'; import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter'; export default function Ideas() { const toast = useToast(); const { activeSite } = useSiteStore(); const { activeSector, sectors } = useSectorStore(); const { pageSize } = usePageSizeStore(); // Data state const [ideas, setIdeas] = useState([]); const [clusters, setClusters] = useState([]); const [loading, setLoading] = useState(true); // Total counts for footer widget (not page-filtered) const [totalNew, setTotalNew] = useState(0); const [totalQueued, setTotalQueued] = useState(0); const [totalCompleted, setTotalCompleted] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0); // Actual total count (unfiltered) for header metrics - not affected by filters const [actualTotalIdeas, setActualTotalIdeas] = useState(0); // Dynamic filter options // Initialize as undefined to distinguish "not loaded yet" from "loaded but empty array" const [statusOptions, setStatusOptions] = useState(undefined); const [contentTypeOptions, setContentTypeOptions] = useState(undefined); const [contentStructureOptions, setContentStructureOptions] = useState(undefined); const [clusterOptions, setClusterOptions] = useState(undefined); // Filter state const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [clusterFilter, setClusterFilter] = useState(''); const [structureFilter, setStructureFilter] = useState(''); const [typeFilter, setTypeFilter] = useState(''); 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('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 [editingIdea, setEditingIdea] = useState(null); const [formData, setFormData] = useState({ idea_title: '', description: '', content_structure: 'article', content_type: 'post', primary_focus_keywords: '', target_keywords: '', keyword_cluster_id: null, status: 'new', estimated_word_count: 1000, }); // Progress modal for AI functions const progressModal = useProgressModal(); // Load clusters for form dropdown (all clusters) 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 and applied filters // This implements cascading filters - each filter's options reflect what's available // given the other currently applied filters const loadFilterOptions = useCallback(async (currentFilters?: { status?: string; content_type?: string; content_structure?: string; cluster?: string; search?: string; }) => { if (!activeSite) return; try { const options = await fetchPlannerIdeasFilterOptions(activeSite.id, currentFilters); setStatusOptions(options.statuses || []); setContentTypeOptions(options.content_types || []); setContentStructureOptions(options.content_structures || []); setClusterOptions(options.clusters || []); } catch (error) { console.error('Error loading filter options:', error); } }, [activeSite]); // Load filter options when site changes (initial load with no filters) useEffect(() => { loadFilterOptions(); }, [activeSite]); // Reload filter options when any filter changes (cascading filters) useEffect(() => { loadFilterOptions({ status: statusFilter || undefined, content_type: typeFilter || undefined, content_structure: structureFilter || undefined, cluster: clusterFilter || undefined, search: searchTerm || undefined, }); }, [statusFilter, typeFilter, structureFilter, clusterFilter, searchTerm, loadFilterOptions]); // Load total metrics for footer widget (site-wide totals, no sector filter) const loadTotalMetrics = useCallback(async () => { try { // Batch all API calls in parallel for better performance const [allRes, queuedRes, completedRes, newRes, imagesRes] = await Promise.all([ // Get all ideas (site-wide) fetchContentIdeas({ page_size: 1, site_id: activeSite?.id, }), // Get ideas with status='queued' fetchContentIdeas({ page_size: 1, site_id: activeSite?.id, status: 'queued', }), // Get ideas with status='completed' fetchContentIdeas({ page_size: 1, site_id: activeSite?.id, status: 'completed', }), // Get ideas with status='new' (those ready to become tasks) fetchContentIdeas({ page_size: 1, site_id: activeSite?.id, status: 'new', }), // Get actual total images count fetchImages({ page_size: 1 }), ]); setActualTotalIdeas(allRes.count || 0); // Store actual total (unfiltered) for header metrics setTotalNew(newRes.count || 0); setTotalQueued(queuedRes.count || 0); setTotalCompleted(completedRes.count || 0); setTotalImagesCount(imagesRes.count || 0); } catch (error) { console.error('Error loading total metrics:', error); } }, [activeSite]); // Load total metrics when sector changes useEffect(() => { loadTotalMetrics(); }, [loadTotalMetrics]); // Load ideas - wrapped in useCallback const loadIdeas = useCallback(async () => { setLoading(true); setShowContent(false); try { const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at'; const filters: ContentIdeasFilters = { ...(searchTerm && { search: searchTerm }), ...(statusFilter && { status: statusFilter }), ...(clusterFilter && { keyword_cluster_id: clusterFilter }), ...(structureFilter && { content_structure: structureFilter }), ...(typeFilter && { content_type: typeFilter }), ...(activeSector?.id && { sector_id: activeSector.id }), page: currentPage, page_size: pageSize, ordering, }; const data = await fetchContentIdeas(filters); setIdeas(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 ideas:', error); toast.error(`Failed to load ideas: ${error.message}`); setShowContent(true); setLoading(false); } }, [currentPage, statusFilter, clusterFilter, structureFilter, typeFilter, sortBy, sortDirection, searchTerm, activeSector, pageSize]); useEffect(() => { loadIdeas(); }, [loadIdeas]); // Listen for site and sector changes and refresh data useEffect(() => { const handleSiteChange = () => { loadIdeas(); }; const handleSectorChange = () => { loadIdeas(); }; window.addEventListener('siteChanged', handleSiteChange); window.addEventListener('sectorChanged', handleSectorChange); return () => { window.removeEventListener('siteChanged', handleSiteChange); window.removeEventListener('sectorChanged', handleSectorChange); }; }, [loadIdeas]); // Reset to page 1 when pageSize changes useEffect(() => { setCurrentPage(1); }, [pageSize]); // 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]); // Handle sorting const handleSort = (field: string, direction: 'asc' | 'desc') => { setSortBy(field || 'created_at'); 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 bulkUpdateContentIdeasStatus(numIds, status); await loadIdeas(); } catch (error: any) { throw error; } }, [loadIdeas]); // Bulk export handler const handleBulkExport = useCallback(async (ids: string[]) => { try { if (!ids || ids.length === 0) { throw new Error('No records selected for export'); } // Build URL with only IDs parameter for bulk export const idsParam = ids.join(','); const exportUrl = `${API_BASE_URL}/v1/planner/ideas/export/?ids=${encodeURIComponent(idsParam)}`; const token = useAuthStore.getState().token; const headers: HeadersInit = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(exportUrl, { method: 'GET', credentials: 'include', headers, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Export failed: ${errorText}`); } const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = 'ideas.csv'; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); } catch (error: any) { throw error; // Let TablePageTemplate handle toast } }, []); // Row action handler const handleRowAction = useCallback(async (action: string, row: ContentIdea) => { if (action === 'queue_to_writer') { if (row.status !== 'new') { toast.error(`Only ideas with status "new" can be queued. Current status: ${row.status}`); return; } try { const result = await bulkQueueIdeasToWriter([row.id]); toast.success(`Queue complete: ${result.created_count || 0} task created`); await loadIdeas(); } catch (error: any) { toast.error(`Failed to queue idea: ${error.message}`); } } }, [toast, loadIdeas]); const handleBulkAction = useCallback(async (action: string, ids: string[]) => { if (action === 'queue_to_writer') { if (ids.length === 0) { toast.error('Please select at least one idea to queue'); return; } try { const numIds = ids.map(id => parseInt(id)); const result = await bulkQueueIdeasToWriter(numIds); toast.success(`Queue complete: ${result.created_count || 0} tasks created from ${ids.length} ideas`); await loadIdeas(); } catch (error: any) { toast.error(`Failed to queue ideas: ${error.message}`); } } else { toast.info(`Bulk action "${action}" for ${ids.length} items`); } }, [toast, loadIdeas]); // Create page config const pageConfig = useMemo(() => { return createIdeasPageConfig({ clusters, activeSector, sectors, sectors, formData, setFormData, searchTerm, setSearchTerm, statusFilter, setStatusFilter, clusterFilter, setClusterFilter, structureFilter, setStructureFilter, typeFilter, setTypeFilter, setCurrentPage, // Dynamic filter options statusOptions, contentTypeOptions, contentStructureOptions, clusterOptions, }); }, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, statusOptions, contentTypeOptions, contentStructureOptions, clusterOptions]); // Calculate header metrics - use actual counts from API calls (not page data) // This ensures metrics show correct totals across all pages, not just current page // Note: actualTotalIdeas is NOT affected by filters - it always shows the true total 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 'Ideas': // Use actualTotalIdeas (unfiltered) for header metrics - not affected by filters value = actualTotalIdeas || 0; break; case 'New': // Use totalNew from loadTotalMetrics() (ideas with status='new') value = totalNew; break; case 'Queued': // Use totalQueued from loadTotalMetrics() (ideas with status='queued') value = totalQueued; break; case 'Completed': // Use totalCompleted from loadTotalMetrics() (ideas with status='completed') value = totalCompleted; break; default: value = metric.calculate({ ideas, totalCount }); } return { label: metric.label, value, accentColor: metric.accentColor, tooltip: (metric as any).tooltip, }; }); }, [pageConfig?.headerMetrics, ideas, totalCount, totalNew, totalQueued, totalCompleted, actualTotalIdeas]); const resetForm = useCallback(() => { setFormData({ idea_title: '', description: '', content_structure: 'article', content_type: 'post', primary_focus_keywords: '', target_keywords: '', keyword_cluster_id: null, status: 'new', estimated_word_count: 1000, }); setIsEditMode(false); setEditingIdea(null); }, []); // Handle create/edit const handleSave = async () => { try { if (isEditMode && editingIdea) { await updateContentIdea(editingIdea.id, formData); toast.success('Idea updated successfully'); } else { await createContentIdea(formData); toast.success('Idea created successfully'); } setIsModalOpen(false); resetForm(); loadIdeas(); } catch (error: any) { toast.error(error.message || 'Unable to save idea. Please try again.'); } }; return ( <> , color: 'yellow' }} parent="Planner" /> , onClick: () => handleBulkAction('queue_to_writer', selectedIds), variant: 'success', }} onFilterChange={(key, value) => { const stringValue = value === null || value === undefined ? '' : String(value); if (key === 'search') { setSearchTerm(stringValue); } else if (key === 'status') { setStatusFilter(stringValue); setCurrentPage(1); } else if (key === 'keyword_cluster_id') { setClusterFilter(stringValue); setCurrentPage(1); } else if (key === 'content_structure') { setStructureFilter(stringValue); setCurrentPage(1); } else if (key === 'content_type') { setTypeFilter(stringValue); setCurrentPage(1); } setCurrentPage(1); }} onEdit={(row) => { setEditingIdea(row); setFormData({ idea_title: row.idea_title, description: row.description || '', content_structure: row.content_structure || 'article', content_type: row.content_type || 'post', primary_focus_keywords: row.primary_focus_keywords || '', target_keywords: row.target_keywords || '', keyword_cluster_id: row.keyword_cluster_id || null, status: row.status || 'new', estimated_word_count: row.estimated_word_count || 1000, }); setIsEditMode(true); setIsModalOpen(true); }} onCreate={() => { resetForm(); setIsModalOpen(true); }} createLabel="Add Idea" onCreateIcon={} onDelete={async (id: number) => { await deleteContentIdea(id); loadIdeas(); }} onBulkDelete={async (ids: number[]) => { const result = await bulkDeleteContentIdeas(ids); // Clear selection first setSelectedIds([]); // Reset to page 1 if we deleted all items on current page if (currentPage > 1 && ideas.length <= ids.length) { setCurrentPage(1); } // Always reload data to refresh the table await loadIdeas(); return result; }} onBulkExport={handleBulkExport} onBulkUpdateStatus={handleBulkUpdateStatus} onBulkAction={handleBulkAction} onRowAction={handleRowAction} getItemDisplayName={(row: ContentIdea) => row.idea_title} onExport={async () => { try { const params = new URLSearchParams(); if (searchTerm) params.set('search', searchTerm); if (statusFilter) params.set('status', statusFilter); if (clusterFilter) params.set('keyword_cluster_id', clusterFilter); if (structureFilter) params.set('content_structure', structureFilter); if (typeFilter) params.set('content_type', typeFilter); const exportUrl = `${API_BASE_URL}/v1/planner/ideas/export/?${params.toString()}`; const token = useAuthStore.getState().token; const headers: HeadersInit = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(exportUrl, { method: 'GET', credentials: 'include', headers }); if (!response.ok) { throw new Error(`Export failed: ${response.statusText}`); } const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = 'ideas.csv'; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); toast.success('Export successful', 'Ideas exported successfully.'); } catch (error: any) { toast.error(`Export failed: ${error.message}`); } }} onExportIcon={} selectionLabel="idea" pagination={{ currentPage, totalPages, totalCount, onPageChange: setCurrentPage, }} selection={{ selectedIds, onSelectionChange: setSelectedIds, }} sorting={{ sortBy, sortDirection, onSort: handleSort, }} headerMetrics={headerMetrics} onFilterReset={() => { setSearchTerm(''); setStatusFilter(''); setClusterFilter(''); setStructureFilter(''); setTypeFilter(''); setCurrentPage(1); }} /> {/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */} 0 ? Math.round(((totalQueued + totalCompleted) / totalCount) * 100) : 0}%` }, { label: 'Pending', value: totalNew }, { label: 'From Clusters', value: clusters.length }, ], progress: { value: totalCount > 0 ? Math.round(((totalQueued + totalCompleted) / totalCount) * 100) : 0, label: 'Converted', color: 'amber', }, hint: totalNew > 0 ? `${totalNew} ideas ready to become tasks` : 'All ideas converted!', statusInsight: totalNew > 0 ? `Select ideas and queue them to Writer to start content generation.` : (totalQueued + totalCompleted) > 0 ? `Ideas queued. Go to Writer Tasks to generate content.` : `No ideas yet. Generate ideas from Clusters page.`, }} module="planner" showCredits={true} analyticsHref="/account/usage" /> {/* Progress Modal for AI Functions */} { const wasCompleted = progressModal.progress.status === 'completed'; progressModal.closeModal(); // Reload data after modal closes (if completed) if (wasCompleted) { loadIdeas(); } }} /> {/* Create/Edit Modal */} { setIsModalOpen(false); resetForm(); }} onSubmit={handleSave} title={isEditMode ? 'Edit Idea' : 'Add Idea'} submitLabel={isEditMode ? 'Update' : 'Create'} fields={pageConfig.formFields(clusters)} /> ); }