568 lines
19 KiB
TypeScript
568 lines
19 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,
|
|
createContentIdea,
|
|
updateContentIdea,
|
|
deleteContentIdea,
|
|
bulkDeleteContentIdeas,
|
|
bulkUpdateContentIdeasStatus,
|
|
bulkQueueIdeasToWriter,
|
|
ContentIdea,
|
|
ContentIdeasFilters,
|
|
ContentIdeaCreateData,
|
|
fetchClusters,
|
|
Cluster,
|
|
} 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 { usePageSizeStore } from '../../store/pageSizeStore';
|
|
import PageHeader from '../../components/common/PageHeader';
|
|
import ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter';
|
|
|
|
export default function Ideas() {
|
|
const toast = useToast();
|
|
const { activeSector } = 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 [totalInTasks, setTotalInTasks] = useState(0);
|
|
const [totalPending, setTotalPending] = useState(0);
|
|
|
|
// 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',
|
|
target_keywords: '',
|
|
keyword_cluster_id: null,
|
|
status: 'new',
|
|
estimated_word_count: 1000,
|
|
});
|
|
|
|
// Progress modal for AI functions
|
|
const progressModal = useProgressModal();
|
|
|
|
// 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 total metrics for footer widget (not affected by pagination)
|
|
const loadTotalMetrics = useCallback(async () => {
|
|
try {
|
|
// Get ideas with status='queued' or 'completed' (those in tasks/writer)
|
|
const queuedRes = await fetchContentIdeas({
|
|
page_size: 1,
|
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
|
status: 'queued',
|
|
});
|
|
const completedRes = await fetchContentIdeas({
|
|
page_size: 1,
|
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
|
status: 'completed',
|
|
});
|
|
setTotalInTasks((queuedRes.count || 0) + (completedRes.count || 0));
|
|
|
|
// Get ideas with status='new' (those ready to become tasks)
|
|
const newRes = await fetchContentIdeas({
|
|
page_size: 1,
|
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
|
status: 'new',
|
|
});
|
|
setTotalPending(newRes.count || 0);
|
|
} catch (error) {
|
|
console.error('Error loading total metrics:', error);
|
|
}
|
|
}, [activeSector]);
|
|
|
|
// 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 }),
|
|
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
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
if (currentPage === 1) {
|
|
loadIdeas();
|
|
} else {
|
|
setCurrentPage(1);
|
|
}
|
|
}, 500);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [searchTerm, currentPage, loadIdeas]);
|
|
|
|
// 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');
|
|
}
|
|
toast.info('Export functionality coming soon');
|
|
} catch (error: any) {
|
|
throw error;
|
|
}
|
|
}, []);
|
|
|
|
// 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,
|
|
formData,
|
|
setFormData,
|
|
searchTerm,
|
|
setSearchTerm,
|
|
statusFilter,
|
|
setStatusFilter,
|
|
clusterFilter,
|
|
setClusterFilter,
|
|
structureFilter,
|
|
setStructureFilter,
|
|
typeFilter,
|
|
setTypeFilter,
|
|
setCurrentPage,
|
|
});
|
|
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
|
|
|
|
// Calculate header metrics
|
|
const headerMetrics = useMemo(() => {
|
|
if (!pageConfig?.headerMetrics) return [];
|
|
return pageConfig.headerMetrics.map((metric) => ({
|
|
label: metric.label,
|
|
value: metric.calculate({ ideas, totalCount }),
|
|
accentColor: metric.accentColor,
|
|
tooltip: (metric as any).tooltip,
|
|
}));
|
|
}, [pageConfig?.headerMetrics, ideas, totalCount]);
|
|
|
|
const resetForm = useCallback(() => {
|
|
setFormData({
|
|
idea_title: '',
|
|
description: '',
|
|
content_structure: 'article',
|
|
content_type: 'post',
|
|
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',
|
|
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 () => {
|
|
toast.info('Export functionality coming soon');
|
|
}}
|
|
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 */}
|
|
<ThreeWidgetFooter
|
|
submoduleColor="amber"
|
|
pageProgress={{
|
|
title: 'Page Progress',
|
|
submoduleColor: 'amber',
|
|
metrics: [
|
|
{ label: 'Ideas', value: totalCount },
|
|
{ label: 'In Tasks', value: totalInTasks, percentage: `${totalCount > 0 ? Math.round((totalInTasks / totalCount) * 100) : 0}%` },
|
|
{ label: 'Pending', value: totalPending },
|
|
{ label: 'From Clusters', value: clusters.length },
|
|
],
|
|
progress: {
|
|
value: totalCount > 0 ? Math.round((totalInTasks / totalCount) * 100) : 0,
|
|
label: 'Converted',
|
|
color: 'amber',
|
|
},
|
|
hint: totalPending > 0
|
|
? `${totalPending} ideas ready to become tasks`
|
|
: 'All ideas converted!',
|
|
}}
|
|
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: clusters.length,
|
|
toHref: '/planner/clusters',
|
|
progress: 100,
|
|
color: 'blue',
|
|
},
|
|
{
|
|
fromLabel: 'Clusters',
|
|
fromValue: clusters.length,
|
|
fromHref: '/planner/clusters',
|
|
actionLabel: 'Generate Ideas',
|
|
toLabel: 'Ideas',
|
|
toValue: totalCount,
|
|
progress: 100,
|
|
color: 'green',
|
|
},
|
|
{
|
|
fromLabel: 'Ideas',
|
|
fromValue: totalCount,
|
|
actionLabel: 'Create Tasks',
|
|
toLabel: 'Tasks',
|
|
toValue: ideas.filter(i => i.status === 'queued' || i.status === 'completed').length,
|
|
toHref: '/writer/tasks',
|
|
progress: totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'queued' || i.status === 'completed').length / totalCount) * 100) : 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: clusters.length, color: 'green' },
|
|
{ label: 'Ideas Generated', value: totalCount, color: 'amber' },
|
|
],
|
|
writerItems: [
|
|
{ label: 'Content Generated', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
|
|
{ label: 'Images Created', value: 0, color: 'purple' },
|
|
{ label: 'Published', value: 0, color: 'green' },
|
|
],
|
|
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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|