/** * Review Page - Built with TablePageTemplate * Shows content with status='review' ready for publishing */ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchContent, Content, ContentListResponse, ContentFilters, fetchAPI, } from '../../services/api'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { CheckCircleIcon } from '../../icons'; import { ClipboardDocumentCheckIcon } from '@heroicons/react/24/outline'; import { createReviewPageConfig } from '../../config/pages/review.config'; import { useSectorStore } from '../../store/sectorStore'; import { usePageSizeStore } from '../../store/pageSizeStore'; import PageHeader from '../../components/common/PageHeader'; import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter'; export default function Review() { const toast = useToast(); const navigate = useNavigate(); const { activeSector } = useSectorStore(); const { pageSize } = usePageSizeStore(); // Data state const [content, setContent] = useState([]); const [loading, setLoading] = useState(true); // Filter state - default to review status const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('review'); // Default to review 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); // Load content - filtered for review status const loadContent = useCallback(async () => { setLoading(true); setShowContent(false); try { const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at'; const filters: ContentFilters = { ...(searchTerm && { search: searchTerm }), status: 'review', // Always filter for review status page: currentPage, page_size: pageSize, ordering, }; const data: ContentListResponse = await fetchContent(filters); setContent(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 content:', error); toast.error(`Failed to load content: ${error.message}`); setShowContent(true); setLoading(false); } }, [currentPage, sortBy, sortDirection, searchTerm, pageSize, toast]); useEffect(() => { loadContent(); }, [loadContent]); // Listen for site and sector changes and refresh data useEffect(() => { const handleSiteChange = () => { loadContent(); }; window.addEventListener('site-changed', handleSiteChange); window.addEventListener('sector-changed', handleSiteChange); return () => { window.removeEventListener('site-changed', handleSiteChange); window.removeEventListener('sector-changed', handleSiteChange); }; }, [loadContent]); // Sorting handler const handleSort = useCallback((column: string) => { if (column === sortBy) { setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); } else { setSortBy(column); setSortDirection('asc'); } setCurrentPage(1); }, [sortBy]); // Handle row click - navigate to content view const handleRowClick = useCallback((row: Content) => { navigate(`/writer/content/${row.id}`); }, [navigate]); // Build page config const pageConfig = useMemo(() => createReviewPageConfig({ activeSector, searchTerm, setSearchTerm, statusFilter, setStatusFilter, setCurrentPage, onRowClick: handleRowClick, }), [activeSector, searchTerm, statusFilter, handleRowClick] ); // Header metrics (calculated from loaded data) const headerMetrics = useMemo(() => pageConfig.headerMetrics.map(metric => ({ ...metric, value: metric.calculate({ content, totalCount }), tooltip: (metric as any).tooltip, })), [pageConfig.headerMetrics, content, totalCount] ); // Export handler const handleBulkExport = useCallback(async (ids: string[]) => { toast.info(`Exporting ${ids.length} item(s)...`); return { success: true }; }, [toast]); // Publish to WordPress - single item const handlePublishSingle = useCallback(async (row: Content) => { console.group('🚀 [Review.handlePublishSingle] WordPress Publish Started'); console.log('📄 Content Details:', { id: row.id, title: row.title, status: row.status, timestamp: new Date().toISOString() }); try { // Log the payload being sent const payload = { content_id: row.id, destinations: ['wordpress'] }; console.log('📦 Request Payload:', payload); console.log('🌐 API Endpoint: POST /v1/publisher/publish/'); console.log('📡 Sending request to backend...'); const response = await fetchAPI('/v1/publisher/publish/', { method: 'POST', body: JSON.stringify(payload) }); console.log('📬 Full API Response:', JSON.parse(JSON.stringify(response))); console.log('📊 Response Structure:', { success: response.success, has_data: !!response.data, has_results: !!response.data?.results, results_count: response.data?.results?.length || 0, has_error: !!response.error, has_message: !!response.message }); // Handle the response with results array // Note: Backend wraps result in 'data' key via success_response() const result = response.data || response; // Fallback to response if no data wrapper if (result.success && result.results) { console.log('✅ Overall publish success: true'); console.log('📋 Publish Results:', result.results); // Check individual destination results const wordpressResult = result.results.find((r: any) => r.destination === 'wordpress'); console.log('🎯 WordPress Result:', wordpressResult); if (wordpressResult && wordpressResult.success) { console.log('✅ WordPress publish successful:', { external_id: wordpressResult.external_id, url: wordpressResult.url, publishing_record_id: wordpressResult.publishing_record_id }); toast.success(`Published "${row.title}" to WordPress`); // Update content status to published in UI loadContent(); } else { const error = wordpressResult?.error || wordpressResult?.message || 'Publishing failed'; console.error('❌ WordPress publish failed:', { error: error, result: wordpressResult }); toast.error(`Failed to publish: ${error}`); } } else if (!result.success) { // Handle overall failure console.error('❌ Publish failed (overall):', { error: result.error, message: result.message, results: result.results }); // Try to extract error from results let errorMsg = result.error || result.message || 'Publishing failed'; if (result.results && result.results.length > 0) { const failedResult = result.results[0]; errorMsg = failedResult.error || failedResult.message || errorMsg; } toast.error(`Failed to publish: ${errorMsg}`); } else { console.warn('⚠️ Unexpected response format:', response); toast.error('Failed to publish: Unexpected response format'); } } catch (error: any) { console.error('❌ Exception during publish:', { content_id: row.id, error_type: error.constructor.name, error_message: error.message, error_stack: error.stack, error_object: error }); toast.error(`Failed to publish to WordPress: ${error.message || 'Network error'}`); } finally { console.groupEnd(); } }, [loadContent, toast]); // Approve content - single item (changes status from 'review' to 'approved') const handleApproveSingle = useCallback(async (row: Content) => { try { await fetchAPI(`/v1/writer/content/${row.id}/`, { method: 'PATCH', body: JSON.stringify({ status: 'approved' }) }); toast.success(`Approved "${row.title}"`); loadContent(); } catch (error: any) { toast.error(`Failed to approve: ${error.message || 'Network error'}`); } }, [loadContent, toast]); // Approve content - bulk (changes status from 'review' to 'approved') const handleApproveBulk = useCallback(async (ids: string[]) => { try { let successCount = 0; let failedCount = 0; for (const id of ids) { try { await fetchAPI(`/v1/writer/content/${id}/`, { method: 'PATCH', body: JSON.stringify({ status: 'approved' }) }); successCount++; } catch (error) { failedCount++; console.error(`Error approving content ${id}:`, error); } } if (successCount > 0) { toast.success(`Successfully approved ${successCount} item(s)`); } if (failedCount > 0) { toast.warning(`${failedCount} item(s) failed to approve`); } loadContent(); } catch (error: any) { toast.error(`Failed to approve: ${error.message || 'Network error'}`); } }, [loadContent, toast]); // Publish to WordPress - bulk const handlePublishBulk = useCallback(async (ids: string[]) => { try { let successCount = 0; let failedCount = 0; // Publish each item individually for (const id of ids) { try { const response = await fetchAPI('/v1/publisher/publish/', { method: 'POST', body: JSON.stringify({ content_id: parseInt(id), destinations: ['wordpress'] }) }); // Backend wraps result in 'data' key via success_response() const result = response.data || response; if (result.success) { successCount++; } else { failedCount++; console.warn(`Failed to publish content ${id}:`, result.error); } } catch (error) { failedCount++; console.error(`Error publishing content ${id}:`, error); } } if (successCount > 0) { toast.success(`Successfully published ${successCount} item(s) to WordPress`); } if (failedCount > 0) { toast.warning(`${failedCount} item(s) failed to publish`); } loadContent(); // Reload to reflect changes } catch (error: any) { console.error('Bulk WordPress publish error:', error); toast.error(`Failed to bulk publish to WordPress: ${error.message || 'Network error'}`); } }, [loadContent, toast]); // Bulk action handler const handleBulkAction = useCallback(async (action: string, ids: string[]) => { if (action === 'bulk_approve') { await handleApproveBulk(ids); } else if (action === 'bulk_publish_wordpress') { await handlePublishBulk(ids); } else { toast.info(`Bulk action "${action}" for ${ids.length} items`); } }, [handleApproveBulk, handlePublishBulk, toast]); // Row action handler const handleRowAction = useCallback(async (action: string, row: Content) => { if (action === 'approve') { await handleApproveSingle(row); } else if (action === 'publish_wordpress') { await handlePublishSingle(row); } else if (action === 'view') { navigate(`/writer/content/${row.id}`); } }, [handleApproveSingle, handlePublishSingle, navigate]); // Delete handler (single) const handleDelete = useCallback(async (id: string) => { try { await fetchAPI(`/v1/writer/content/${id}/`, { method: 'DELETE', }); toast.success('Content deleted successfully'); loadContent(); } catch (error: any) { toast.error(`Failed to delete content: ${error.message}`); throw error; } }, [loadContent, toast]); // Delete handler (bulk) const handleBulkDelete = useCallback(async (ids: string[]) => { try { // Delete each item individually let successCount = 0; for (const id of ids) { try { await fetchAPI(`/v1/writer/content/${id}/`, { method: 'DELETE', }); successCount++; } catch (error) { console.error(`Failed to delete content ${id}:`, error); } } toast.success(`Deleted ${successCount} content item(s)`); loadContent(); } catch (error: any) { toast.error(`Failed to bulk delete: ${error.message}`); throw error; } }, [loadContent, toast]); return ( <> , color: 'emerald' }} parent="Writer" /> , onClick: () => handleBulkAction('bulk_approve', selectedIds), variant: 'success', }} onFilterChange={(key, value) => { const stringValue = value === null || value === undefined ? '' : String(value); if (key === 'search') { setSearchTerm(stringValue); } setCurrentPage(1); }} onBulkExport={handleBulkExport} onBulkAction={handleBulkAction} onDelete={handleDelete} onBulkDelete={handleBulkDelete} getItemDisplayName={(row: Content) => row.title || `Content #${row.id}`} onExport={async () => { toast.info('Export functionality coming soon'); }} selectionLabel="content" pagination={{ currentPage, totalPages, totalCount, onPageChange: setCurrentPage, }} selection={{ selectedIds, onSelectionChange: setSelectedIds, }} sorting={{ sortBy, sortDirection, onSort: handleSort, }} headerMetrics={headerMetrics} onFilterReset={() => { setSearchTerm(''); setCurrentPage(1); }} onRowAction={handleRowAction} /> , accentColor: 'blue', }, ]} /> ); }