570 lines
19 KiB
TypeScript
570 lines
19 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 { 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<Cluster[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// 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 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 (
|
|
<>
|
|
<PageHeader
|
|
title="Clusters"
|
|
description="Group keywords into topic clusters"
|
|
badge={{ icon: <GroupIcon />, color: 'purple' }}
|
|
breadcrumb="Planner"
|
|
actions={
|
|
<Link
|
|
to="/planner/ideas"
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors"
|
|
>
|
|
Generate Ideas
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</Link>
|
|
}
|
|
/>
|
|
<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);
|
|
}}
|
|
/>
|
|
|
|
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
|
<ModuleMetricsFooter
|
|
metrics={[
|
|
{
|
|
title: 'Keywords',
|
|
value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0).toLocaleString(),
|
|
subtitle: `in ${totalCount} clusters`,
|
|
icon: <ListIcon className="w-5 h-5" />,
|
|
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: <BoltIcon className="w-5 h-5" />,
|
|
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: <GroupIcon className="w-5 h-5" />,
|
|
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
|
|
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()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|