471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
/**
|
|
* 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<Content[]>([]);
|
|
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<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);
|
|
|
|
// 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 (
|
|
<>
|
|
<PageHeader
|
|
title="Content Review"
|
|
badge={{ icon: <ClipboardDocumentCheckIcon />, color: 'emerald' }}
|
|
parent="Writer"
|
|
/>
|
|
<TablePageTemplate
|
|
columns={pageConfig.columns}
|
|
data={content}
|
|
loading={loading}
|
|
showContent={showContent}
|
|
filters={pageConfig.filters}
|
|
filterValues={{
|
|
search: searchTerm,
|
|
}}
|
|
primaryAction={{
|
|
label: 'Approve Selected',
|
|
icon: <CheckCircleIcon className="w-4 h-4" />,
|
|
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}
|
|
/>
|
|
<ModuleMetricsFooter
|
|
metrics={[
|
|
{
|
|
title: 'Ready to Publish',
|
|
value: content.length,
|
|
subtitle: 'Total review items',
|
|
icon: <CheckCircleIcon className="w-5 h-5" />,
|
|
accentColor: 'blue',
|
|
},
|
|
]}
|
|
/>
|
|
</>
|
|
);
|
|
}
|