metricsa dn backedn fixes
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchContentImages,
|
||||
fetchImages,
|
||||
} from '../../services/api';
|
||||
import ActivityLog from '../../components/Automation/ActivityLog';
|
||||
import ConfigModal from '../../components/Automation/ConfigModal';
|
||||
@@ -130,8 +130,8 @@ const AutomationPage: React.FC = () => {
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||||
fetchContentImages({ page_size: 1, site_id: siteId }),
|
||||
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
||||
fetchImages({ page_size: 1 }),
|
||||
fetchImages({ page_size: 1, status: 'pending' }),
|
||||
]);
|
||||
|
||||
setMetrics({
|
||||
@@ -249,8 +249,8 @@ const AutomationPage: React.FC = () => {
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||||
fetchContentImages({ page_size: 1, site_id: siteId }),
|
||||
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
||||
fetchImages({ page_size: 1 }),
|
||||
fetchImages({ page_size: 1, status: 'pending' }),
|
||||
]);
|
||||
|
||||
setMetrics({
|
||||
|
||||
@@ -11,15 +11,10 @@ import { useOnboardingStore } from "../../store/onboardingStore";
|
||||
import { useBillingStore } from "../../store/billingStore";
|
||||
import { GridIcon, PlusIcon } from "../../icons";
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchContentImages,
|
||||
fetchSites,
|
||||
Site,
|
||||
} from "../../services/api";
|
||||
import { getDashboardStats } from "../../services/billing.api";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
import { useToast } from "../../components/ui/toast/ToastContainer";
|
||||
@@ -155,49 +150,33 @@ export default function Home() {
|
||||
const fetchDashboardData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
const siteId = siteFilter === 'all' ? undefined : siteFilter;
|
||||
|
||||
// Fetch pipeline counts sequentially to avoid rate limiting
|
||||
const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const ideasRes = await fetchContentIdeas({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const tasksRes = await fetchTasks({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const contentRes = await fetchContent({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const imagesRes = await fetchContentImages({ page_size: 1, site_id: siteId });
|
||||
// Fetch real dashboard stats from API
|
||||
const stats = await getDashboardStats({
|
||||
site_id: siteId,
|
||||
days: 7
|
||||
});
|
||||
|
||||
const totalKeywords = keywordsRes.count || 0;
|
||||
const totalClusters = clustersRes.count || 0;
|
||||
const totalIdeas = ideasRes.count || 0;
|
||||
const totalTasks = tasksRes.count || 0;
|
||||
const totalContent = contentRes.count || 0;
|
||||
const totalImages = imagesRes.count || 0;
|
||||
const publishedContent = Math.floor(totalContent * 0.6); // Placeholder
|
||||
|
||||
// Calculate completion percentage
|
||||
const completionPercentage = totalKeywords > 0
|
||||
? Math.round((publishedContent / totalKeywords) * 100)
|
||||
// Update pipeline data from real API data
|
||||
const { pipeline, counts } = stats;
|
||||
const completionPercentage = pipeline.keywords > 0
|
||||
? Math.round((pipeline.published / pipeline.keywords) * 100)
|
||||
: 0;
|
||||
|
||||
// Update pipeline data
|
||||
setPipelineData({
|
||||
sites: sites.length,
|
||||
keywords: totalKeywords,
|
||||
clusters: totalClusters,
|
||||
ideas: totalIdeas,
|
||||
tasks: totalTasks,
|
||||
drafts: totalContent,
|
||||
published: publishedContent,
|
||||
sites: pipeline.sites,
|
||||
keywords: pipeline.keywords,
|
||||
clusters: pipeline.clusters,
|
||||
ideas: pipeline.ideas,
|
||||
tasks: pipeline.tasks,
|
||||
drafts: pipeline.drafts,
|
||||
published: pipeline.published,
|
||||
completionPercentage: Math.min(completionPercentage, 100),
|
||||
});
|
||||
|
||||
// Generate attention items based on data
|
||||
// Generate attention items based on real data
|
||||
const attentionList: AttentionItem[] = [];
|
||||
|
||||
// Check for sites without sectors
|
||||
@@ -213,130 +192,85 @@ export default function Home() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check for content needing images
|
||||
const contentWithoutImages = totalContent - Math.floor(totalContent * 0.7);
|
||||
if (contentWithoutImages > 0 && totalContent > 0) {
|
||||
// Check for content needing images (content in review without all images generated)
|
||||
const contentWithPendingImages = counts.images.pending;
|
||||
if (contentWithPendingImages > 0) {
|
||||
attentionList.push({
|
||||
id: 'needs_images',
|
||||
type: 'pending_review',
|
||||
title: 'articles need images',
|
||||
count: contentWithoutImages,
|
||||
title: 'images pending',
|
||||
count: contentWithPendingImages,
|
||||
description: 'Generate images before publishing',
|
||||
actionLabel: 'Generate Images',
|
||||
actionHref: '/writer/images',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for content in review
|
||||
if (counts.content.review > 0) {
|
||||
attentionList.push({
|
||||
id: 'pending_review',
|
||||
type: 'pending_review',
|
||||
title: 'articles ready for review',
|
||||
count: counts.content.review,
|
||||
description: 'Review and publish content',
|
||||
actionLabel: 'Review Content',
|
||||
actionHref: '/writer/content?status=review',
|
||||
});
|
||||
}
|
||||
|
||||
setAttentionItems(attentionList);
|
||||
|
||||
// Update content velocity (using mock calculations based on totals)
|
||||
const weeklyArticles = Math.floor(totalContent * 0.15);
|
||||
const monthlyArticles = Math.floor(totalContent * 0.4);
|
||||
setContentVelocity({
|
||||
thisWeek: {
|
||||
articles: weeklyArticles,
|
||||
words: weeklyArticles * 1500,
|
||||
images: Math.floor(totalImages * 0.15)
|
||||
},
|
||||
thisMonth: {
|
||||
articles: monthlyArticles,
|
||||
words: monthlyArticles * 1500,
|
||||
images: Math.floor(totalImages * 0.4)
|
||||
},
|
||||
total: {
|
||||
articles: totalContent,
|
||||
words: totalContent * 1500,
|
||||
images: totalImages
|
||||
},
|
||||
trend: totalContent > 0 ? Math.floor(Math.random() * 40) - 10 : 0,
|
||||
});
|
||||
// Update content velocity from real API data
|
||||
setContentVelocity(stats.content_velocity);
|
||||
|
||||
// Generate mock recent activity based on actual data
|
||||
const activityList: ActivityItem[] = [];
|
||||
if (totalClusters > 0) {
|
||||
activityList.push({
|
||||
id: 'cluster_1',
|
||||
type: 'clustering',
|
||||
title: `Clustered ${Math.min(45, totalKeywords)} keywords → ${Math.min(8, totalClusters)} clusters`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
href: '/planner/clusters',
|
||||
});
|
||||
}
|
||||
if (totalContent > 0) {
|
||||
activityList.push({
|
||||
id: 'content_1',
|
||||
type: 'content',
|
||||
title: `Generated ${Math.min(5, totalContent)} articles`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000),
|
||||
href: '/writer/content',
|
||||
});
|
||||
}
|
||||
if (totalImages > 0) {
|
||||
activityList.push({
|
||||
id: 'images_1',
|
||||
type: 'images',
|
||||
title: `Created ${Math.min(15, totalImages)} image prompts`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
href: '/writer/images',
|
||||
});
|
||||
}
|
||||
if (publishedContent > 0) {
|
||||
activityList.push({
|
||||
id: 'published_1',
|
||||
type: 'published',
|
||||
title: `Published article to WordPress`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
href: '/writer/published',
|
||||
});
|
||||
}
|
||||
if (totalKeywords > 0) {
|
||||
activityList.push({
|
||||
id: 'keywords_1',
|
||||
type: 'keywords',
|
||||
title: `Added ${Math.min(23, totalKeywords)} keywords`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000),
|
||||
href: '/planner/keywords',
|
||||
});
|
||||
}
|
||||
// Update recent activity from real API data (convert timestamp strings to Date objects)
|
||||
const activityList: ActivityItem[] = stats.recent_activity.map(item => ({
|
||||
...item,
|
||||
timestamp: new Date(item.timestamp),
|
||||
}));
|
||||
setRecentActivity(activityList);
|
||||
|
||||
// Update AI operations (mock data based on content created)
|
||||
const clusteringOps = totalClusters > 0 ? Math.ceil(totalClusters / 3) : 0;
|
||||
const ideasOps = totalIdeas > 0 ? Math.ceil(totalIdeas / 5) : 0;
|
||||
const contentOps = totalContent;
|
||||
const imageOps = totalImages > 0 ? Math.ceil(totalImages / 3) : 0;
|
||||
|
||||
// Update AI operations from real API data
|
||||
// Map operation types to display types
|
||||
const operationTypeMap: Record<string, string> = {
|
||||
'clustering': 'clustering',
|
||||
'idea_generation': 'ideas',
|
||||
'content_generation': 'content',
|
||||
'image_generation': 'images',
|
||||
'image_prompt_extraction': 'images',
|
||||
};
|
||||
|
||||
const mappedOperations = stats.ai_operations.operations.map(op => ({
|
||||
type: operationTypeMap[op.type] || op.type,
|
||||
count: op.count,
|
||||
credits: op.credits,
|
||||
}));
|
||||
|
||||
// Ensure all expected types exist
|
||||
const expectedTypes = ['clustering', 'ideas', 'content', 'images'];
|
||||
for (const type of expectedTypes) {
|
||||
if (!mappedOperations.find(op => op.type === type)) {
|
||||
mappedOperations.push({ type, count: 0, credits: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
setAIOperations({
|
||||
period: '7d',
|
||||
operations: [
|
||||
{ type: 'clustering', count: clusteringOps, credits: clusteringOps * 10 },
|
||||
{ type: 'ideas', count: ideasOps, credits: ideasOps * 2 },
|
||||
{ type: 'content', count: contentOps, credits: contentOps * 50 },
|
||||
{ type: 'images', count: imageOps, credits: imageOps * 5 },
|
||||
],
|
||||
totals: {
|
||||
count: clusteringOps + ideasOps + contentOps + imageOps,
|
||||
credits: (clusteringOps * 10) + (ideasOps * 2) + (contentOps * 50) + (imageOps * 5),
|
||||
successRate: 98.5,
|
||||
avgCreditsPerOp: contentOps > 0 ? 18.6 : 0,
|
||||
},
|
||||
period: stats.ai_operations.period,
|
||||
operations: mappedOperations,
|
||||
totals: stats.ai_operations.totals,
|
||||
});
|
||||
|
||||
// Set automation status (would come from API in real implementation)
|
||||
// Set automation status (would come from automation API)
|
||||
setAutomationData({
|
||||
status: sites.length > 0 ? 'active' : 'not_configured',
|
||||
schedule: sites.length > 0 ? 'Daily 9 AM' : undefined,
|
||||
lastRun: sites.length > 0 ? {
|
||||
lastRun: sites.length > 0 && counts.content.total > 0 ? {
|
||||
timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000),
|
||||
clustered: Math.min(12, totalKeywords),
|
||||
ideas: Math.min(8, totalIdeas),
|
||||
content: Math.min(5, totalContent),
|
||||
images: Math.min(15, totalImages),
|
||||
clustered: pipeline.clusters,
|
||||
ideas: pipeline.ideas,
|
||||
content: counts.content.total,
|
||||
images: counts.images.total,
|
||||
success: true,
|
||||
} : undefined,
|
||||
nextRun: sites.length > 0 ? new Date(Date.now() + 12 * 60 * 60 * 1000) : undefined,
|
||||
@@ -352,7 +286,7 @@ export default function Home() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteFilter, sites.length, toast]);
|
||||
}, [siteFilter, sites, toast]);
|
||||
|
||||
// Fetch dashboard data when filter changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchClusters,
|
||||
fetchImages,
|
||||
createCluster,
|
||||
updateCluster,
|
||||
deleteCluster,
|
||||
@@ -41,6 +42,7 @@ export default function Clusters() {
|
||||
// Total counts for footer widget (not page-filtered)
|
||||
const [totalWithIdeas, setTotalWithIdeas] = useState(0);
|
||||
const [totalReady, setTotalReady] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -97,6 +99,10 @@ export default function Clusters() {
|
||||
status: 'new',
|
||||
});
|
||||
setTotalReady(newRes.count || 0);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
@@ -184,18 +190,17 @@ export default function Clusters() {
|
||||
};
|
||||
}, [loadClusters]);
|
||||
|
||||
// Debounced search
|
||||
// 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(() => {
|
||||
if (currentPage === 1) {
|
||||
loadClusters();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
// Always reset to page 1 when search changes
|
||||
// The main useEffect will handle reloading when currentPage changes
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadClusters]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Reset to page 1 when pageSize changes
|
||||
useEffect(() => {
|
||||
@@ -380,16 +385,43 @@ export default function Clusters() {
|
||||
handleRowAction,
|
||||
]);
|
||||
|
||||
// Calculate header metrics
|
||||
// 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 [];
|
||||
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]);
|
||||
|
||||
// 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':
|
||||
// Sum of keywords across all clusters on current page (this is acceptable for display)
|
||||
value = clusters.reduce((sum: number, c) => sum + (c.keywords_count || 0), 0);
|
||||
break;
|
||||
case 'Volume':
|
||||
// Sum of volume across all clusters on current page (this is acceptable for display)
|
||||
value = clusters.reduce((sum: number, c) => sum + (c.total_volume || 0), 0);
|
||||
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]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
@@ -589,7 +621,7 @@ export default function Clusters() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContentIdeas,
|
||||
fetchImages,
|
||||
createContentIdea,
|
||||
updateContentIdea,
|
||||
deleteContentIdea,
|
||||
@@ -44,6 +45,7 @@ export default function Ideas() {
|
||||
// Total counts for footer widget (not page-filtered)
|
||||
const [totalInTasks, setTotalInTasks] = useState(0);
|
||||
const [totalPending, setTotalPending] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -117,6 +119,10 @@ export default function Ideas() {
|
||||
status: 'new',
|
||||
});
|
||||
setTotalPending(newRes.count || 0);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
@@ -189,18 +195,17 @@ export default function Ideas() {
|
||||
setCurrentPage(1);
|
||||
}, [pageSize]);
|
||||
|
||||
// Debounced search
|
||||
// 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(() => {
|
||||
if (currentPage === 1) {
|
||||
loadIdeas();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
// Always reset to page 1 when search changes
|
||||
// The main useEffect will handle reloading when currentPage changes
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadIdeas]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||
@@ -289,16 +294,43 @@ export default function Ideas() {
|
||||
});
|
||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
|
||||
|
||||
// Calculate header metrics
|
||||
// Calculate header metrics - use totalInTasks/totalPending 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 [];
|
||||
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]);
|
||||
|
||||
// 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 'Ideas':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'New':
|
||||
// Use totalPending from loadTotalMetrics() (ideas with status='new')
|
||||
value = totalPending;
|
||||
break;
|
||||
case 'Queued':
|
||||
// Use totalInTasks from loadTotalMetrics() (ideas with status='queued')
|
||||
value = totalInTasks;
|
||||
break;
|
||||
case 'Completed':
|
||||
// Calculate completed from totalCount - (totalPending + totalInTasks)
|
||||
value = Math.max(0, totalCount - totalPending - totalInTasks);
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ ideas, totalCount });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
};
|
||||
});
|
||||
}, [pageConfig?.headerMetrics, ideas, totalCount, totalPending, totalInTasks]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
@@ -522,7 +554,7 @@ export default function Ideas() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchImages,
|
||||
createKeyword,
|
||||
updateKeyword,
|
||||
deleteKeyword,
|
||||
@@ -50,6 +51,7 @@ export default function Keywords() {
|
||||
const [totalClustered, setTotalClustered] = useState(0);
|
||||
const [totalUnmapped, setTotalUnmapped] = useState(0);
|
||||
const [totalVolume, setTotalVolume] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state - match Keywords.tsx
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -141,6 +143,10 @@ export default function Keywords() {
|
||||
// For now, we'll just calculate from current data or set to 0
|
||||
// TODO: Backend should provide total volume as an aggregated metric
|
||||
setTotalVolume(0);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
@@ -524,16 +530,43 @@ export default function Keywords() {
|
||||
activeSite,
|
||||
]);
|
||||
|
||||
// Calculate header metrics from config (matching reference plugin KPIs from kpi-config.php)
|
||||
// Calculate header metrics - use totalClustered/totalUnmapped 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 [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ keywords, totalCount, clusters }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip, // Add tooltip support
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters]);
|
||||
|
||||
// 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 'Keywords':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'Clustered':
|
||||
// Use totalClustered from loadTotalMetrics() instead of filtering page data
|
||||
value = totalClustered;
|
||||
break;
|
||||
case 'Unmapped':
|
||||
// Use totalUnmapped from loadTotalMetrics() instead of filtering page data
|
||||
value = totalUnmapped;
|
||||
break;
|
||||
case 'Volume':
|
||||
// Use totalVolume from loadTotalMetrics() (if implemented) or keep original
|
||||
value = totalVolume || keywords.reduce((sum: number, k) => sum + (k.volume || 0), 0);
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ keywords, totalCount, clusters });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
};
|
||||
});
|
||||
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters, totalClustered, totalUnmapped, totalVolume]);
|
||||
|
||||
// Calculate workflow insights based on UX doc principles
|
||||
const workflowStats = useMemo(() => {
|
||||
@@ -819,7 +852,7 @@ export default function Keywords() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
Content,
|
||||
ContentListResponse,
|
||||
ContentFilters,
|
||||
@@ -34,7 +35,12 @@ export default function Approved() {
|
||||
// Data state
|
||||
const [content, setContent] = useState<Content[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
// Total counts for footer widget and header metrics (not page-filtered)
|
||||
const [totalOnSite, setTotalOnSite] = useState(0);
|
||||
const [totalPendingPublish, setTotalPendingPublish] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state - default to approved status
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [publishStatusFilter, setPublishStatusFilter] = useState('');
|
||||
@@ -50,6 +56,36 @@ export default function Approved() {
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
|
||||
// Load total metrics for footer widget and header metrics (not affected by pagination)
|
||||
const loadTotalMetrics = useCallback(async () => {
|
||||
try {
|
||||
// Fetch all approved content to calculate totals
|
||||
const data = await fetchContent({
|
||||
status: 'published', // Backend uses 'published' for approved content
|
||||
page_size: 1000, // Fetch enough to count
|
||||
});
|
||||
|
||||
const allContent = data.results || [];
|
||||
// Count by external_id presence
|
||||
const onSite = allContent.filter(c => c.external_id).length;
|
||||
const pending = allContent.filter(c => !c.external_id).length;
|
||||
|
||||
setTotalOnSite(onSite);
|
||||
setTotalPendingPublish(pending);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load total metrics on mount
|
||||
useEffect(() => {
|
||||
loadTotalMetrics();
|
||||
}, [loadTotalMetrics]);
|
||||
|
||||
// Load content - filtered for approved status (API still uses 'published' internally)
|
||||
const loadContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -137,18 +173,17 @@ export default function Approved() {
|
||||
setCurrentPage(1);
|
||||
}, [pageSize]);
|
||||
|
||||
// Debounced search
|
||||
// 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(() => {
|
||||
if (currentPage === 1) {
|
||||
loadContent();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
// Always reset to page 1 when search changes
|
||||
// The main useEffect will handle reloading when currentPage changes
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadContent]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||
@@ -292,15 +327,38 @@ export default function Approved() {
|
||||
});
|
||||
}, [searchTerm, publishStatusFilter, activeSector, navigate]);
|
||||
|
||||
// Calculate header metrics
|
||||
// Calculate header metrics - use totals 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 [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ content, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, content, totalCount]);
|
||||
|
||||
// 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 'Approved':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'On Site':
|
||||
// Use totalOnSite from loadTotalMetrics()
|
||||
value = totalOnSite;
|
||||
break;
|
||||
case 'Pending':
|
||||
// Use totalPendingPublish from loadTotalMetrics()
|
||||
value = totalPendingPublish;
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ content, totalCount });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
};
|
||||
});
|
||||
}, [pageConfig?.headerMetrics, content, totalCount, totalOnSite, totalPendingPublish]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -398,7 +456,7 @@ export default function Approved() {
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: 0,
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: 0,
|
||||
color: 'purple',
|
||||
@@ -431,7 +489,7 @@ export default function Approved() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Articles Published', value: totalCount, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
Content as ContentType,
|
||||
ContentFilters,
|
||||
generateImagePrompts,
|
||||
@@ -34,7 +35,13 @@ export default function Content() {
|
||||
// Data state
|
||||
const [content, setContent] = useState<ContentType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
// Total counts for footer widget and header metrics (not page-filtered)
|
||||
const [totalDraft, setTotalDraft] = useState(0);
|
||||
const [totalReview, setTotalReview] = useState(0);
|
||||
const [totalPublished, setTotalPublished] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('draft');
|
||||
@@ -55,6 +62,46 @@ export default function Content() {
|
||||
const progressModal = useProgressModal();
|
||||
const hasReloadedRef = useRef(false);
|
||||
|
||||
// Load total metrics for footer widget and header metrics (not affected by pagination)
|
||||
const loadTotalMetrics = useCallback(async () => {
|
||||
try {
|
||||
// Get content with status='draft'
|
||||
const draftRes = await fetchContent({
|
||||
page_size: 1,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
status: 'draft',
|
||||
});
|
||||
setTotalDraft(draftRes.count || 0);
|
||||
|
||||
// Get content with status='review'
|
||||
const reviewRes = await fetchContent({
|
||||
page_size: 1,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
status: 'review',
|
||||
});
|
||||
setTotalReview(reviewRes.count || 0);
|
||||
|
||||
// Get content with status='published'
|
||||
const publishedRes = await fetchContent({
|
||||
page_size: 1,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
status: 'published',
|
||||
});
|
||||
setTotalPublished(publishedRes.count || 0);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
}, [activeSector]);
|
||||
|
||||
// Load total metrics when sector changes
|
||||
useEffect(() => {
|
||||
loadTotalMetrics();
|
||||
}, [loadTotalMetrics]);
|
||||
|
||||
// Load content - wrapped in useCallback
|
||||
const loadContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -115,18 +162,17 @@ export default function Content() {
|
||||
setCurrentPage(1);
|
||||
}, [pageSize]);
|
||||
|
||||
// Debounced search
|
||||
// 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(() => {
|
||||
if (currentPage === 1) {
|
||||
loadContent();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
// Always reset to page 1 when search changes
|
||||
// The main useEffect will handle reloading when currentPage changes
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadContent]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||
@@ -160,16 +206,43 @@ export default function Content() {
|
||||
handleRowClick,
|
||||
]);
|
||||
|
||||
// Calculate header metrics
|
||||
// Calculate header metrics - use totals 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 [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ content, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, content, totalCount]);
|
||||
|
||||
// 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 'Content':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'Draft':
|
||||
// Use totalDraft from loadTotalMetrics()
|
||||
value = totalDraft;
|
||||
break;
|
||||
case 'In Review':
|
||||
// Use totalReview from loadTotalMetrics()
|
||||
value = totalReview;
|
||||
break;
|
||||
case 'Published':
|
||||
// Use totalPublished from loadTotalMetrics()
|
||||
value = totalPublished;
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ content, totalCount });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
};
|
||||
});
|
||||
}, [pageConfig?.headerMetrics, content, totalCount, totalDraft, totalReview, totalPublished]);
|
||||
|
||||
const handleRowAction = useCallback(async (action: string, row: ContentType) => {
|
||||
if (action === 'view_on_wordpress') {
|
||||
@@ -347,7 +420,7 @@ export default function Content() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: totalCount, color: 'blue' },
|
||||
{ label: 'Images Created', value: content.filter(c => c.has_generated_images).length, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContentImages,
|
||||
fetchImages,
|
||||
ContentImagesGroup,
|
||||
ContentImagesResponse,
|
||||
fetchImageGenerationSettings,
|
||||
@@ -34,7 +35,13 @@ export default function Images() {
|
||||
// Data state
|
||||
const [images, setImages] = useState<ContentImagesGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
||||
// Total counts for footer widget and header metrics (not page-filtered)
|
||||
const [totalComplete, setTotalComplete] = useState(0);
|
||||
const [totalPartial, setTotalPartial] = useState(0);
|
||||
const [totalPending, setTotalPending] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0); // Actual images count
|
||||
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
@@ -69,6 +76,49 @@ export default function Images() {
|
||||
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
|
||||
const [modalImageUrl, setModalImageUrl] = useState<string | null>(null);
|
||||
|
||||
// Load total metrics for footer widget and header metrics (not affected by pagination)
|
||||
const loadTotalMetrics = useCallback(async () => {
|
||||
try {
|
||||
// Fetch content-grouped images for status counts
|
||||
const data: ContentImagesResponse = await fetchContentImages({});
|
||||
const allImages = data.results || [];
|
||||
|
||||
// Count by overall_status (content-level status)
|
||||
let complete = 0;
|
||||
let partial = 0;
|
||||
let pending = 0;
|
||||
|
||||
allImages.forEach(img => {
|
||||
switch (img.overall_status) {
|
||||
case 'complete':
|
||||
complete++;
|
||||
break;
|
||||
case 'partial':
|
||||
partial++;
|
||||
break;
|
||||
case 'pending':
|
||||
pending++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setTotalComplete(complete);
|
||||
setTotalPartial(partial);
|
||||
setTotalPending(pending);
|
||||
|
||||
// Fetch ACTUAL total images count from the images endpoint
|
||||
const imagesData = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesData.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load total metrics on mount
|
||||
useEffect(() => {
|
||||
loadTotalMetrics();
|
||||
}, [loadTotalMetrics]);
|
||||
|
||||
// Load images - wrapped in useCallback
|
||||
const loadImages = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -155,18 +205,17 @@ export default function Images() {
|
||||
};
|
||||
}, [loadImages]);
|
||||
|
||||
// Debounced search
|
||||
// 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(() => {
|
||||
if (currentPage === 1) {
|
||||
loadImages();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
// Always reset to page 1 when search changes
|
||||
// The main useEffect will handle reloading when currentPage changes
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadImages]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||
@@ -438,16 +487,54 @@ export default function Images() {
|
||||
});
|
||||
}, [searchTerm, statusFilter, maxInArticleImages, handleGenerateImages, handleImageClick]);
|
||||
|
||||
// Calculate header metrics
|
||||
// Calculate header metrics - use totals 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 [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ images, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, images, totalCount]);
|
||||
|
||||
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||
// Also add a "Total Images" metric at the end
|
||||
const baseMetrics = pageConfig.headerMetrics.map((metric) => {
|
||||
let value: number;
|
||||
|
||||
switch (metric.label) {
|
||||
case 'Content':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'Complete':
|
||||
// Use totalComplete from loadTotalMetrics()
|
||||
value = totalComplete;
|
||||
break;
|
||||
case 'Partial':
|
||||
// Use totalPartial from loadTotalMetrics()
|
||||
value = totalPartial;
|
||||
break;
|
||||
case 'Pending':
|
||||
// Use totalPending from loadTotalMetrics()
|
||||
value = totalPending;
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ images, totalCount });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
};
|
||||
});
|
||||
|
||||
// Add total images count metric
|
||||
baseMetrics.push({
|
||||
label: 'Total Images',
|
||||
value: totalImagesCount,
|
||||
accentColor: 'purple' as const,
|
||||
tooltip: 'Total number of images across all content',
|
||||
});
|
||||
|
||||
return baseMetrics;
|
||||
}, [pageConfig?.headerMetrics, images, totalCount, totalComplete, totalPartial, totalPending, totalImagesCount]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -617,7 +704,7 @@ export default function Images() {
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: totalCount,
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: 100,
|
||||
color: 'purple',
|
||||
@@ -650,7 +737,7 @@ export default function Images() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: totalCount, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Articles Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
Content,
|
||||
ContentListResponse,
|
||||
ContentFilters,
|
||||
@@ -31,6 +32,7 @@ export default function Review() {
|
||||
// Data state
|
||||
const [content, setContent] = useState<Content[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state - default to review status
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -83,6 +85,19 @@ export default function Review() {
|
||||
loadContent();
|
||||
}, [loadContent]);
|
||||
|
||||
// Load total images count
|
||||
useEffect(() => {
|
||||
const loadImageCount = async () => {
|
||||
try {
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading image count:', error);
|
||||
}
|
||||
};
|
||||
loadImageCount();
|
||||
}, []);
|
||||
|
||||
// Listen for site and sector changes and refresh data
|
||||
useEffect(() => {
|
||||
const handleSiteChange = () => {
|
||||
@@ -494,7 +509,7 @@ export default function Review() {
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: 0,
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: 0,
|
||||
color: 'purple',
|
||||
@@ -527,7 +542,7 @@ export default function Review() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Articles Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchTasks,
|
||||
fetchImages,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
@@ -42,6 +43,13 @@ export default function Tasks() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Total counts for footer widget and header metrics (not page-filtered)
|
||||
const [totalQueued, setTotalQueued] = useState(0);
|
||||
const [totalProcessing, setTotalProcessing] = useState(0);
|
||||
const [totalCompleted, setTotalCompleted] = useState(0);
|
||||
const [totalFailed, setTotalFailed] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -97,6 +105,54 @@ export default function Tasks() {
|
||||
loadClusters();
|
||||
}, []);
|
||||
|
||||
// Load total metrics for footer widget and header metrics (not affected by pagination)
|
||||
const loadTotalMetrics = useCallback(async () => {
|
||||
try {
|
||||
// Get tasks with status='queued'
|
||||
const queuedRes = await fetchTasks({
|
||||
page_size: 1,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
status: 'queued',
|
||||
});
|
||||
setTotalQueued(queuedRes.count || 0);
|
||||
|
||||
// Get tasks with status='in_progress'
|
||||
const processingRes = await fetchTasks({
|
||||
page_size: 1,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
status: 'in_progress',
|
||||
});
|
||||
setTotalProcessing(processingRes.count || 0);
|
||||
|
||||
// Get tasks with status='completed'
|
||||
const completedRes = await fetchTasks({
|
||||
page_size: 1,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
status: 'completed',
|
||||
});
|
||||
setTotalCompleted(completedRes.count || 0);
|
||||
|
||||
// Get tasks with status='failed'
|
||||
const failedRes = await fetchTasks({
|
||||
page_size: 1,
|
||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||
status: 'failed',
|
||||
});
|
||||
setTotalFailed(failedRes.count || 0);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
}, [activeSector]);
|
||||
|
||||
// Load total metrics when sector changes
|
||||
useEffect(() => {
|
||||
loadTotalMetrics();
|
||||
}, [loadTotalMetrics]);
|
||||
|
||||
// Load tasks - wrapped in useCallback
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -167,18 +223,17 @@ export default function Tasks() {
|
||||
}, [pageSize]);
|
||||
|
||||
|
||||
// Debounced search
|
||||
// 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(() => {
|
||||
if (currentPage === 1) {
|
||||
loadTasks();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
// Always reset to page 1 when search changes
|
||||
// The main useEffect will handle reloading when currentPage changes
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadTasks]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||
@@ -318,16 +373,47 @@ export default function Tasks() {
|
||||
});
|
||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, sourceFilter]);
|
||||
|
||||
// Calculate header metrics
|
||||
// Calculate header metrics - use totals 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 [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ tasks, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, tasks, totalCount]);
|
||||
|
||||
// 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 'Tasks':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'In Queue':
|
||||
// Use totalQueued from loadTotalMetrics()
|
||||
value = totalQueued;
|
||||
break;
|
||||
case 'Processing':
|
||||
// Use totalProcessing from loadTotalMetrics()
|
||||
value = totalProcessing;
|
||||
break;
|
||||
case 'Completed':
|
||||
// Use totalCompleted from loadTotalMetrics()
|
||||
value = totalCompleted;
|
||||
break;
|
||||
case 'Failed':
|
||||
// Use totalFailed from loadTotalMetrics()
|
||||
value = totalFailed;
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ tasks, totalCount });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
};
|
||||
});
|
||||
}, [pageConfig?.headerMetrics, tasks, totalCount, totalQueued, totalProcessing, totalCompleted, totalFailed]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
@@ -507,7 +593,7 @@ export default function Tasks() {
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: 0,
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: 0,
|
||||
color: 'purple',
|
||||
@@ -540,7 +626,7 @@ export default function Tasks() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: tasks.filter(t => t.status === 'completed').length, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -1042,3 +1042,90 @@ export async function getPublicPlans(): Promise<Plan[]> {
|
||||
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||
return fetchAPI('/v1/billing/usage-summary/');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DASHBOARD STATS (Real data for home page)
|
||||
// ============================================================================
|
||||
|
||||
export interface DashboardAIOperation {
|
||||
type: string;
|
||||
count: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface DashboardAIOperations {
|
||||
period: string;
|
||||
operations: DashboardAIOperation[];
|
||||
totals: {
|
||||
count: number;
|
||||
credits: number;
|
||||
successRate: number;
|
||||
avgCreditsPerOp: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface DashboardContentVelocity {
|
||||
thisWeek: {
|
||||
articles: number;
|
||||
words: number;
|
||||
images: number;
|
||||
};
|
||||
thisMonth: {
|
||||
articles: number;
|
||||
words: number;
|
||||
images: number;
|
||||
};
|
||||
total: {
|
||||
articles: number;
|
||||
words: number;
|
||||
images: number;
|
||||
};
|
||||
trend: number;
|
||||
}
|
||||
|
||||
export interface DashboardPipeline {
|
||||
sites: number;
|
||||
keywords: number;
|
||||
clusters: number;
|
||||
ideas: number;
|
||||
tasks: number;
|
||||
drafts: number;
|
||||
published: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
ai_operations: DashboardAIOperations;
|
||||
recent_activity: DashboardActivityItem[];
|
||||
content_velocity: DashboardContentVelocity;
|
||||
pipeline: DashboardPipeline;
|
||||
counts: {
|
||||
content: {
|
||||
total: number;
|
||||
draft: number;
|
||||
review: number;
|
||||
published: number;
|
||||
};
|
||||
images: {
|
||||
total: number;
|
||||
generated: number;
|
||||
pending: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardStats(params?: { site_id?: number; days?: number }): Promise<DashboardStats> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.site_id) searchParams.append('site_id', params.site_id.toString());
|
||||
if (params?.days) searchParams.append('days', params.days.toString());
|
||||
const query = searchParams.toString();
|
||||
return fetchAPI(`/v1/account/dashboard/stats/${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user