Files
igny8/frontend/src/pages/Planner/Ideas.tsx
2026-02-19 07:38:56 +00:00

690 lines
24 KiB
TypeScript

/**
* 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<ContentIdea[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
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<FilterOption[] | undefined>(undefined);
const [contentTypeOptions, setContentTypeOptions] = useState<FilterOption[] | undefined>(undefined);
const [contentStructureOptions, setContentStructureOptions] = useState<FilterOption[] | undefined>(undefined);
const [clusterOptions, setClusterOptions] = useState<FilterOption[] | undefined>(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<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
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 [editingIdea, setEditingIdea] = useState<ContentIdea | null>(null);
const [formData, setFormData] = useState<ContentIdeaCreateData>({
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 (
<>
<PageHeader
title="Ideas"
badge={{ icon: <LightBulbIcon />, color: 'yellow' }}
parent="Planner"
/>
<TablePageTemplate
columns={pageConfig.columns}
data={ideas}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
keyword_cluster_id: clusterFilter,
content_structure: structureFilter,
content_type: typeFilter,
}}
primaryAction={{
label: 'Queue to Writer',
icon: <ArrowRightIcon className="w-4 h-4" />,
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={<PlusIcon />}
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={<DownloadIcon />}
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 */}
<StandardThreeWidgetFooter
submoduleColor="amber"
pageProgress={{
title: 'Page Progress',
submoduleColor: 'amber',
metrics: [
{ label: 'Ideas', value: totalCount },
{ label: 'In Tasks', value: totalQueued + totalCompleted, percentage: `${totalCount > 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 */}
<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}
stepLogs={progressModal.stepLogs}
onClose={() => {
const wasCompleted = progressModal.progress.status === 'completed';
progressModal.closeModal();
// Reload data after modal closes (if completed)
if (wasCompleted) {
loadIdeas();
}
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Idea' : 'Add Idea'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields(clusters)}
/>
</>
);
}