Files
igny8/frontend/src/pages/Planner/Clusters.tsx
IGNY8 VPS (Salman) 885158e152 master - part 2
2025-12-30 09:47:58 +00:00

677 lines
22 KiB
TypeScript

/**
* 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<Cluster[]>([]);
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<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false);
const [tempVolumeMin, setTempVolumeMin] = useState<number | ''>('');
const [tempVolumeMax, setTempVolumeMax] = useState<number | ''>('');
const volumeDropdownRef = useRef<HTMLDivElement>(null);
const volumeButtonRef = useRef<HTMLButtonElement>(null);
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>('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<Cluster | null>(null);
const [formData, setFormData] = useState<ClusterCreateData>({
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 (
<>
<PageHeader
title="Clusters"
badge={{ icon: <GroupIcon />, color: 'purple' }}
parent="Planner"
/>
<TablePageTemplate
columns={pageConfig.columns}
data={clusters}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
difficulty: difficultyFilter,
volumeMin: volumeMin,
volumeMax: volumeMax,
}}
onFilterChange={(key, value) => {
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={<PlusIcon />}
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={<DownloadIcon />}
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 */}
<ThreeWidgetFooter
submoduleColor="green"
pageProgress={{
title: 'Page Progress',
submoduleColor: 'green',
metrics: [
{ label: 'Clusters', value: totalCount },
{ label: 'With Ideas', value: totalWithIdeas, percentage: `${totalCount > 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
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={() => {
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 */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Cluster' : 'Add Cluster'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields()}
/>
</>
);
}