metricsa dn backedn fixes

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-29 04:33:22 +00:00
parent 53fdebf733
commit 0ffd21b9bf
17 changed files with 929 additions and 266 deletions

View File

@@ -170,6 +170,7 @@ class Igny8AdminSite(UnfoldAdminSite):
'models': [ 'models': [
('igny8_core_auth', 'Plan'), ('igny8_core_auth', 'Plan'),
('igny8_core_auth', 'Subscription'), ('igny8_core_auth', 'Subscription'),
('billing', 'BillingConfiguration'),
('billing', 'Invoice'), ('billing', 'Invoice'),
('billing', 'Payment'), ('billing', 'Payment'),
('billing', 'CreditPackage'), ('billing', 'CreditPackage'),

View File

@@ -5,7 +5,8 @@ from django.urls import path
from igny8_core.api.account_views import ( from igny8_core.api.account_views import (
AccountSettingsViewSet, AccountSettingsViewSet,
TeamManagementViewSet, TeamManagementViewSet,
UsageAnalyticsViewSet UsageAnalyticsViewSet,
DashboardStatsViewSet
) )
urlpatterns = [ urlpatterns = [
@@ -28,4 +29,9 @@ urlpatterns = [
path('usage/analytics/', UsageAnalyticsViewSet.as_view({ path('usage/analytics/', UsageAnalyticsViewSet.as_view({
'get': 'overview' 'get': 'overview'
}), name='usage-analytics'), }), name='usage-analytics'),
# Dashboard Stats (real data for home page)
path('dashboard/stats/', DashboardStatsViewSet.as_view({
'get': 'stats'
}), name='dashboard-stats'),
] ]

View File

@@ -10,6 +10,7 @@ from django.contrib.auth import get_user_model
from django.db.models import Q, Count, Sum from django.db.models import Q, Count, Sum
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema, extend_schema_view
from igny8_core.auth.models import Account from igny8_core.auth.models import Account
@@ -242,3 +243,216 @@ class UsageAnalyticsViewSet(viewsets.ViewSet):
'total_usage': abs(transactions.filter(amount__lt=0).aggregate(Sum('amount'))['amount__sum'] or 0), 'total_usage': abs(transactions.filter(amount__lt=0).aggregate(Sum('amount'))['amount__sum'] or 0),
'total_purchases': transactions.filter(amount__gt=0).aggregate(Sum('amount'))['amount__sum'] or 0, 'total_purchases': transactions.filter(amount__gt=0).aggregate(Sum('amount'))['amount__sum'] or 0,
}) })
@extend_schema_view(
stats=extend_schema(tags=['Account']),
)
class DashboardStatsViewSet(viewsets.ViewSet):
"""Dashboard statistics - real data for home page widgets"""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def stats(self, request):
"""
Get dashboard statistics for the home page.
Query params:
- site_id: Filter by site (optional, defaults to all sites)
- days: Number of days for AI operations (default: 7)
Returns:
- ai_operations: Real credit usage by operation type
- recent_activity: Recent notifications
- content_velocity: Content created this week/month
- images_count: Actual total images count
- published_count: Actual published content count
"""
account = request.user.account
site_id = request.query_params.get('site_id')
days = int(request.query_params.get('days', 7))
# Import models here to avoid circular imports
from igny8_core.modules.writer.models import Images, Content
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
from igny8_core.business.notifications.models import Notification
from igny8_core.business.billing.models import CreditUsageLog
from igny8_core.auth.models import Site
# Build base filter for site
site_filter = {}
if site_id:
try:
site_filter['site_id'] = int(site_id)
except (ValueError, TypeError):
pass
# ========== AI OPERATIONS (from CreditUsageLog) ==========
start_date = timezone.now() - timedelta(days=days)
usage_query = CreditUsageLog.objects.filter(
account=account,
created_at__gte=start_date
)
# Get operations grouped by type
operations_data = usage_query.values('operation_type').annotate(
count=Count('id'),
credits=Sum('credits_used')
).order_by('-credits')
# Calculate totals
total_ops = usage_query.count()
total_credits = usage_query.aggregate(total=Sum('credits_used'))['total'] or 0
# Format operations for frontend
operations = []
for op in operations_data:
op_type = op['operation_type'] or 'other'
operations.append({
'type': op_type,
'count': op['count'] or 0,
'credits': op['credits'] or 0,
})
ai_operations = {
'period': f'{days}d',
'operations': operations,
'totals': {
'count': total_ops,
'credits': total_credits,
'successRate': 98.5, # TODO: calculate from actual success/failure
'avgCreditsPerOp': round(total_credits / total_ops, 1) if total_ops > 0 else 0,
}
}
# ========== RECENT ACTIVITY (from Notifications) ==========
recent_notifications = Notification.objects.filter(
account=account
).order_by('-created_at')[:10]
recent_activity = []
for notif in recent_notifications:
# Map notification type to activity type
activity_type_map = {
'ai_clustering_complete': 'clustering',
'ai_ideas_complete': 'ideas',
'ai_content_complete': 'content',
'ai_images_complete': 'images',
'ai_prompts_complete': 'images',
'content_published': 'published',
'wp_sync_success': 'published',
}
activity_type = activity_type_map.get(notif.notification_type, 'system')
# Map notification type to href
href_map = {
'clustering': '/planner/clusters',
'ideas': '/planner/ideas',
'content': '/writer/content',
'images': '/writer/images',
'published': '/writer/published',
}
recent_activity.append({
'id': str(notif.id),
'type': activity_type,
'title': notif.title,
'description': notif.message[:100] if notif.message else '',
'timestamp': notif.created_at.isoformat(),
'href': href_map.get(activity_type, '/dashboard'),
})
# ========== CONTENT COUNTS ==========
content_base = Content.objects.filter(account=account)
if site_filter:
content_base = content_base.filter(**site_filter)
total_content = content_base.count()
draft_content = content_base.filter(status='draft').count()
review_content = content_base.filter(status='review').count()
published_content = content_base.filter(status='published').count()
# ========== IMAGES COUNT (actual images, not content with images) ==========
images_base = Images.objects.filter(account=account)
if site_filter:
images_base = images_base.filter(**site_filter)
total_images = images_base.count()
generated_images = images_base.filter(status='generated').count()
pending_images = images_base.filter(status='pending').count()
# ========== CONTENT VELOCITY ==========
now = timezone.now()
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
# This week's content
week_content = content_base.filter(created_at__gte=week_ago).count()
week_images = images_base.filter(created_at__gte=week_ago).count()
# This month's content
month_content = content_base.filter(created_at__gte=month_ago).count()
month_images = images_base.filter(created_at__gte=month_ago).count()
# Estimate words (avg 1500 per article)
content_velocity = {
'thisWeek': {
'articles': week_content,
'words': week_content * 1500,
'images': week_images,
},
'thisMonth': {
'articles': month_content,
'words': month_content * 1500,
'images': month_images,
},
'total': {
'articles': total_content,
'words': total_content * 1500,
'images': total_images,
},
'trend': 0, # TODO: calculate actual trend
}
# ========== PIPELINE COUNTS ==========
keywords_base = Keywords.objects.filter(account=account)
clusters_base = Clusters.objects.filter(account=account)
ideas_base = ContentIdeas.objects.filter(account=account)
if site_filter:
keywords_base = keywords_base.filter(**site_filter)
clusters_base = clusters_base.filter(**site_filter)
ideas_base = ideas_base.filter(**site_filter)
# Get site count
sites_count = Site.objects.filter(account=account, is_active=True).count()
pipeline = {
'sites': sites_count,
'keywords': keywords_base.count(),
'clusters': clusters_base.count(),
'ideas': ideas_base.count(),
'tasks': ideas_base.filter(status='queued').count() + ideas_base.filter(status='completed').count(),
'drafts': draft_content + review_content,
'published': published_content,
}
return Response({
'ai_operations': ai_operations,
'recent_activity': recent_activity,
'content_velocity': content_velocity,
'pipeline': pipeline,
'counts': {
'content': {
'total': total_content,
'draft': draft_content,
'review': review_content,
'published': published_content,
},
'images': {
'total': total_images,
'generated': generated_images,
'pending': pending_images,
},
}
})

View File

@@ -6,7 +6,8 @@ from rest_framework.routers import DefaultRouter
from .account_views import ( from .account_views import (
AccountSettingsViewSet, AccountSettingsViewSet,
TeamManagementViewSet, TeamManagementViewSet,
UsageAnalyticsViewSet UsageAnalyticsViewSet,
DashboardStatsViewSet
) )
router = DefaultRouter() router = DefaultRouter()
@@ -22,5 +23,8 @@ urlpatterns = [
# Usage analytics # Usage analytics
path('usage/analytics/', UsageAnalyticsViewSet.as_view({'get': 'overview'}), name='usage-analytics'), path('usage/analytics/', UsageAnalyticsViewSet.as_view({'get': 'overview'}), name='usage-analytics'),
# Dashboard stats (real data for home page)
path('dashboard/stats/', DashboardStatsViewSet.as_view({'get': 'stats'}), name='dashboard-stats'),
path('', include(router.urls)), path('', include(router.urls)),
] ]

View File

@@ -387,16 +387,17 @@ class AutomationViewSet(viewsets.ViewSet):
return counts, total return counts, total
# Stage 1: Keywords pending clustering (keep previous "pending" semantics but also return status breakdown) # Stage 1: Keywords pending clustering
stage_1_counts, stage_1_total = _counts_by_status( stage_1_counts, stage_1_total = _counts_by_status(
Keywords, Keywords,
extra_filter={'disabled': False} extra_filter={'disabled': False}
) )
# pending definition used by the UI previously (new & not clustered) # FIXED: Stage 1 pending = all keywords with status='new' (ready for clustering)
# This should match the "New" count shown in Keywords metric card
# Previously filtered by cluster__isnull=True which caused mismatch
stage_1_pending = Keywords.objects.filter( stage_1_pending = Keywords.objects.filter(
site=site, site=site,
status='new', status='new',
cluster__isnull=True,
disabled=False disabled=False
).count() ).count()

View File

@@ -4,7 +4,7 @@
All pages with `ThreeWidgetFooter` are calculating metrics using **page-filtered arrays** instead of **total counts** from the API. This causes incorrect metric values when users are viewing paginated results. All pages with `ThreeWidgetFooter` are calculating metrics using **page-filtered arrays** instead of **total counts** from the API. This causes incorrect metric values when users are viewing paginated results.
### Example: ### Example:s
- If there are 100 total keywords with 10 on the current page - If there are 100 total keywords with 10 on the current page
- And 5 keywords on the current page don't have a `cluster_id` - And 5 keywords on the current page don't have a `cluster_id`
- The footer shows "Unmapped: 5" instead of the actual total unmapped count - The footer shows "Unmapped: 5" instead of the actual total unmapped count

View File

@@ -12,7 +12,7 @@ import {
fetchContentIdeas, fetchContentIdeas,
fetchTasks, fetchTasks,
fetchContent, fetchContent,
fetchContentImages, fetchImages,
} from '../../services/api'; } from '../../services/api';
import ActivityLog from '../../components/Automation/ActivityLog'; import ActivityLog from '../../components/Automation/ActivityLog';
import ConfigModal from '../../components/Automation/ConfigModal'; 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: 'draft' }),
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }), fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }), fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
fetchContentImages({ page_size: 1, site_id: siteId }), fetchImages({ page_size: 1 }),
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }), fetchImages({ page_size: 1, status: 'pending' }),
]); ]);
setMetrics({ 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: 'draft' }),
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }), fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }), fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
fetchContentImages({ page_size: 1, site_id: siteId }), fetchImages({ page_size: 1 }),
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }), fetchImages({ page_size: 1, status: 'pending' }),
]); ]);
setMetrics({ setMetrics({

View File

@@ -11,15 +11,10 @@ import { useOnboardingStore } from "../../store/onboardingStore";
import { useBillingStore } from "../../store/billingStore"; import { useBillingStore } from "../../store/billingStore";
import { GridIcon, PlusIcon } from "../../icons"; import { GridIcon, PlusIcon } from "../../icons";
import { import {
fetchKeywords,
fetchClusters,
fetchContentIdeas,
fetchTasks,
fetchContent,
fetchContentImages,
fetchSites, fetchSites,
Site, Site,
} from "../../services/api"; } from "../../services/api";
import { getDashboardStats } from "../../services/billing.api";
import { useSiteStore } from "../../store/siteStore"; import { useSiteStore } from "../../store/siteStore";
import { useSectorStore } from "../../store/sectorStore"; import { useSectorStore } from "../../store/sectorStore";
import { useToast } from "../../components/ui/toast/ToastContainer"; import { useToast } from "../../components/ui/toast/ToastContainer";
@@ -155,49 +150,33 @@ export default function Home() {
const fetchDashboardData = useCallback(async () => { const fetchDashboardData = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
const siteId = siteFilter === 'all' ? undefined : siteFilter; const siteId = siteFilter === 'all' ? undefined : siteFilter;
// Fetch pipeline counts sequentially to avoid rate limiting // Fetch real dashboard stats from API
const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId }); const stats = await getDashboardStats({
await delay(100); site_id: siteId,
const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId }); days: 7
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 });
const totalKeywords = keywordsRes.count || 0; // Update pipeline data from real API data
const totalClusters = clustersRes.count || 0; const { pipeline, counts } = stats;
const totalIdeas = ideasRes.count || 0; const completionPercentage = pipeline.keywords > 0
const totalTasks = tasksRes.count || 0; ? Math.round((pipeline.published / pipeline.keywords) * 100)
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)
: 0; : 0;
// Update pipeline data
setPipelineData({ setPipelineData({
sites: sites.length, sites: pipeline.sites,
keywords: totalKeywords, keywords: pipeline.keywords,
clusters: totalClusters, clusters: pipeline.clusters,
ideas: totalIdeas, ideas: pipeline.ideas,
tasks: totalTasks, tasks: pipeline.tasks,
drafts: totalContent, drafts: pipeline.drafts,
published: publishedContent, published: pipeline.published,
completionPercentage: Math.min(completionPercentage, 100), completionPercentage: Math.min(completionPercentage, 100),
}); });
// Generate attention items based on data // Generate attention items based on real data
const attentionList: AttentionItem[] = []; const attentionList: AttentionItem[] = [];
// Check for sites without sectors // Check for sites without sectors
@@ -213,130 +192,85 @@ export default function Home() {
}); });
} }
// Check for content needing images // Check for content needing images (content in review without all images generated)
const contentWithoutImages = totalContent - Math.floor(totalContent * 0.7); const contentWithPendingImages = counts.images.pending;
if (contentWithoutImages > 0 && totalContent > 0) { if (contentWithPendingImages > 0) {
attentionList.push({ attentionList.push({
id: 'needs_images', id: 'needs_images',
type: 'pending_review', type: 'pending_review',
title: 'articles need images', title: 'images pending',
count: contentWithoutImages, count: contentWithPendingImages,
description: 'Generate images before publishing', description: 'Generate images before publishing',
actionLabel: 'Generate Images', actionLabel: 'Generate Images',
actionHref: '/writer/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); setAttentionItems(attentionList);
// Update content velocity (using mock calculations based on totals) // Update content velocity from real API data
const weeklyArticles = Math.floor(totalContent * 0.15); setContentVelocity(stats.content_velocity);
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,
});
// Generate mock recent activity based on actual data // Update recent activity from real API data (convert timestamp strings to Date objects)
const activityList: ActivityItem[] = []; const activityList: ActivityItem[] = stats.recent_activity.map(item => ({
if (totalClusters > 0) { ...item,
activityList.push({ timestamp: new Date(item.timestamp),
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',
});
}
setRecentActivity(activityList); setRecentActivity(activityList);
// Update AI operations (mock data based on content created) // Update AI operations from real API data
const clusteringOps = totalClusters > 0 ? Math.ceil(totalClusters / 3) : 0; // Map operation types to display types
const ideasOps = totalIdeas > 0 ? Math.ceil(totalIdeas / 5) : 0; const operationTypeMap: Record<string, string> = {
const contentOps = totalContent; 'clustering': 'clustering',
const imageOps = totalImages > 0 ? Math.ceil(totalImages / 3) : 0; '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({ setAIOperations({
period: '7d', period: stats.ai_operations.period,
operations: [ operations: mappedOperations,
{ type: 'clustering', count: clusteringOps, credits: clusteringOps * 10 }, totals: stats.ai_operations.totals,
{ 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,
},
}); });
// Set automation status (would come from API in real implementation) // Set automation status (would come from automation API)
setAutomationData({ setAutomationData({
status: sites.length > 0 ? 'active' : 'not_configured', status: sites.length > 0 ? 'active' : 'not_configured',
schedule: sites.length > 0 ? 'Daily 9 AM' : undefined, 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), timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000),
clustered: Math.min(12, totalKeywords), clustered: pipeline.clusters,
ideas: Math.min(8, totalIdeas), ideas: pipeline.ideas,
content: Math.min(5, totalContent), content: counts.content.total,
images: Math.min(15, totalImages), images: counts.images.total,
success: true, success: true,
} : undefined, } : undefined,
nextRun: sites.length > 0 ? new Date(Date.now() + 12 * 60 * 60 * 1000) : undefined, nextRun: sites.length > 0 ? new Date(Date.now() + 12 * 60 * 60 * 1000) : undefined,
@@ -352,7 +286,7 @@ export default function Home() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [siteFilter, sites.length, toast]); }, [siteFilter, sites, toast]);
// Fetch dashboard data when filter changes // Fetch dashboard data when filter changes
useEffect(() => { useEffect(() => {

View File

@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchClusters, fetchClusters,
fetchImages,
createCluster, createCluster,
updateCluster, updateCluster,
deleteCluster, deleteCluster,
@@ -41,6 +42,7 @@ export default function Clusters() {
// Total counts for footer widget (not page-filtered) // Total counts for footer widget (not page-filtered)
const [totalWithIdeas, setTotalWithIdeas] = useState(0); const [totalWithIdeas, setTotalWithIdeas] = useState(0);
const [totalReady, setTotalReady] = useState(0); const [totalReady, setTotalReady] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state // Filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -97,6 +99,10 @@ export default function Clusters() {
status: 'new', status: 'new',
}); });
setTotalReady(newRes.count || 0); setTotalReady(newRes.count || 0);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) { } catch (error) {
console.error('Error loading total metrics:', error); console.error('Error loading total metrics:', error);
} }
@@ -184,18 +190,17 @@ export default function Clusters() {
}; };
}, [loadClusters]); }, [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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (currentPage === 1) { // Always reset to page 1 when search changes
loadClusters(); // The main useEffect will handle reloading when currentPage changes
} else {
setCurrentPage(1); setCurrentPage(1);
}
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadClusters]); }, [searchTerm]);
// Reset to page 1 when pageSize changes // Reset to page 1 when pageSize changes
useEffect(() => { useEffect(() => {
@@ -380,16 +385,43 @@ export default function Clusters() {
handleRowAction, 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(() => { const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return []; if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
// 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, label: metric.label,
value: metric.calculate({ clusters, totalCount }), value,
accentColor: metric.accentColor, accentColor: metric.accentColor,
tooltip: (metric as any).tooltip, tooltip: (metric as any).tooltip,
})); };
}, [pageConfig?.headerMetrics, clusters, totalCount]); });
}, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas]);
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
setFormData({ setFormData({
@@ -589,7 +621,7 @@ export default function Clusters() {
], ],
writerItems: [ writerItems: [
{ label: 'Content Generated', value: 0, color: 'blue' }, { 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' }, { label: 'Published', value: 0, color: 'green' },
], ],
analyticsHref: '/account/usage', analyticsHref: '/account/usage',

View File

@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContentIdeas, fetchContentIdeas,
fetchImages,
createContentIdea, createContentIdea,
updateContentIdea, updateContentIdea,
deleteContentIdea, deleteContentIdea,
@@ -44,6 +45,7 @@ export default function Ideas() {
// Total counts for footer widget (not page-filtered) // Total counts for footer widget (not page-filtered)
const [totalInTasks, setTotalInTasks] = useState(0); const [totalInTasks, setTotalInTasks] = useState(0);
const [totalPending, setTotalPending] = useState(0); const [totalPending, setTotalPending] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state // Filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -117,6 +119,10 @@ export default function Ideas() {
status: 'new', status: 'new',
}); });
setTotalPending(newRes.count || 0); setTotalPending(newRes.count || 0);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) { } catch (error) {
console.error('Error loading total metrics:', error); console.error('Error loading total metrics:', error);
} }
@@ -189,18 +195,17 @@ export default function Ideas() {
setCurrentPage(1); setCurrentPage(1);
}, [pageSize]); }, [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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (currentPage === 1) { // Always reset to page 1 when search changes
loadIdeas(); // The main useEffect will handle reloading when currentPage changes
} else {
setCurrentPage(1); setCurrentPage(1);
}
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadIdeas]); }, [searchTerm]);
// Handle sorting // Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => { const handleSort = (field: string, direction: 'asc' | 'desc') => {
@@ -289,16 +294,43 @@ export default function Ideas() {
}); });
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]); }, [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(() => { const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return []; if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
// 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, label: metric.label,
value: metric.calculate({ ideas, totalCount }), value,
accentColor: metric.accentColor, accentColor: metric.accentColor,
tooltip: (metric as any).tooltip, tooltip: (metric as any).tooltip,
})); };
}, [pageConfig?.headerMetrics, ideas, totalCount]); });
}, [pageConfig?.headerMetrics, ideas, totalCount, totalPending, totalInTasks]);
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
setFormData({ setFormData({
@@ -522,7 +554,7 @@ export default function Ideas() {
], ],
writerItems: [ writerItems: [
{ label: 'Content Generated', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' }, { 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' }, { label: 'Published', value: 0, color: 'green' },
], ],
analyticsHref: '/account/usage', analyticsHref: '/account/usage',

View File

@@ -8,6 +8,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchKeywords, fetchKeywords,
fetchImages,
createKeyword, createKeyword,
updateKeyword, updateKeyword,
deleteKeyword, deleteKeyword,
@@ -50,6 +51,7 @@ export default function Keywords() {
const [totalClustered, setTotalClustered] = useState(0); const [totalClustered, setTotalClustered] = useState(0);
const [totalUnmapped, setTotalUnmapped] = useState(0); const [totalUnmapped, setTotalUnmapped] = useState(0);
const [totalVolume, setTotalVolume] = useState(0); const [totalVolume, setTotalVolume] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state - match Keywords.tsx // Filter state - match Keywords.tsx
const [searchTerm, setSearchTerm] = useState(''); 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 // For now, we'll just calculate from current data or set to 0
// TODO: Backend should provide total volume as an aggregated metric // TODO: Backend should provide total volume as an aggregated metric
setTotalVolume(0); setTotalVolume(0);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) { } catch (error) {
console.error('Error loading total metrics:', error); console.error('Error loading total metrics:', error);
} }
@@ -524,16 +530,43 @@ export default function Keywords() {
activeSite, 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(() => { const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return []; if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
// 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, label: metric.label,
value: metric.calculate({ keywords, totalCount, clusters }), value,
accentColor: metric.accentColor, accentColor: metric.accentColor,
tooltip: (metric as any).tooltip, // Add tooltip support tooltip: (metric as any).tooltip,
})); };
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters]); });
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters, totalClustered, totalUnmapped, totalVolume]);
// Calculate workflow insights based on UX doc principles // Calculate workflow insights based on UX doc principles
const workflowStats = useMemo(() => { const workflowStats = useMemo(() => {
@@ -819,7 +852,7 @@ export default function Keywords() {
], ],
writerItems: [ writerItems: [
{ label: 'Content Generated', value: 0, color: 'blue' }, { 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' }, { label: 'Published', value: 0, color: 'green' },
], ],
creditsUsed: 0, creditsUsed: 0,

View File

@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContent, fetchContent,
fetchImages,
Content, Content,
ContentListResponse, ContentListResponse,
ContentFilters, ContentFilters,
@@ -35,6 +36,11 @@ export default function Approved() {
const [content, setContent] = useState<Content[]>([]); const [content, setContent] = useState<Content[]>([]);
const [loading, setLoading] = useState(true); 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 // Filter state - default to approved status
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [publishStatusFilter, setPublishStatusFilter] = useState(''); const [publishStatusFilter, setPublishStatusFilter] = useState('');
@@ -50,6 +56,36 @@ export default function Approved() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false); 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) // Load content - filtered for approved status (API still uses 'published' internally)
const loadContent = useCallback(async () => { const loadContent = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -137,18 +173,17 @@ export default function Approved() {
setCurrentPage(1); setCurrentPage(1);
}, [pageSize]); }, [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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (currentPage === 1) { // Always reset to page 1 when search changes
loadContent(); // The main useEffect will handle reloading when currentPage changes
} else {
setCurrentPage(1); setCurrentPage(1);
}
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadContent]); }, [searchTerm]);
// Handle sorting // Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => { const handleSort = (field: string, direction: 'asc' | 'desc') => {
@@ -292,15 +327,38 @@ export default function Approved() {
}); });
}, [searchTerm, publishStatusFilter, activeSector, navigate]); }, [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(() => { const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return []; if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
// 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, label: metric.label,
value: metric.calculate({ content, totalCount }), value,
accentColor: metric.accentColor, accentColor: metric.accentColor,
})); };
}, [pageConfig?.headerMetrics, content, totalCount]); });
}, [pageConfig?.headerMetrics, content, totalCount, totalOnSite, totalPendingPublish]);
return ( return (
<> <>
@@ -398,7 +456,7 @@ export default function Approved() {
fromHref: '/writer/content', fromHref: '/writer/content',
actionLabel: 'Generate Images', actionLabel: 'Generate Images',
toLabel: 'Images', toLabel: 'Images',
toValue: 0, toValue: totalImagesCount,
toHref: '/writer/images', toHref: '/writer/images',
progress: 0, progress: 0,
color: 'purple', color: 'purple',
@@ -431,7 +489,7 @@ export default function Approved() {
], ],
writerItems: [ writerItems: [
{ label: 'Content Generated', value: 0, color: 'blue' }, { 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' }, { label: 'Articles Published', value: totalCount, color: 'green' },
], ],
analyticsHref: '/account/usage', analyticsHref: '/account/usage',

View File

@@ -8,6 +8,7 @@ import { Link, useNavigate } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContent, fetchContent,
fetchImages,
Content as ContentType, Content as ContentType,
ContentFilters, ContentFilters,
generateImagePrompts, generateImagePrompts,
@@ -35,6 +36,12 @@ export default function Content() {
const [content, setContent] = useState<ContentType[]>([]); const [content, setContent] = useState<ContentType[]>([]);
const [loading, setLoading] = useState(true); 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 // Filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('draft'); const [statusFilter, setStatusFilter] = useState('draft');
@@ -55,6 +62,46 @@ export default function Content() {
const progressModal = useProgressModal(); const progressModal = useProgressModal();
const hasReloadedRef = useRef(false); 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 // Load content - wrapped in useCallback
const loadContent = useCallback(async () => { const loadContent = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -115,18 +162,17 @@ export default function Content() {
setCurrentPage(1); setCurrentPage(1);
}, [pageSize]); }, [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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (currentPage === 1) { // Always reset to page 1 when search changes
loadContent(); // The main useEffect will handle reloading when currentPage changes
} else {
setCurrentPage(1); setCurrentPage(1);
}
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadContent]); }, [searchTerm]);
// Handle sorting // Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => { const handleSort = (field: string, direction: 'asc' | 'desc') => {
@@ -160,16 +206,43 @@ export default function Content() {
handleRowClick, 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(() => { const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return []; if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
// 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, label: metric.label,
value: metric.calculate({ content, totalCount }), value,
accentColor: metric.accentColor, accentColor: metric.accentColor,
tooltip: (metric as any).tooltip, tooltip: (metric as any).tooltip,
})); };
}, [pageConfig?.headerMetrics, content, totalCount]); });
}, [pageConfig?.headerMetrics, content, totalCount, totalDraft, totalReview, totalPublished]);
const handleRowAction = useCallback(async (action: string, row: ContentType) => { const handleRowAction = useCallback(async (action: string, row: ContentType) => {
if (action === 'view_on_wordpress') { if (action === 'view_on_wordpress') {
@@ -347,7 +420,7 @@ export default function Content() {
], ],
writerItems: [ writerItems: [
{ label: 'Content Generated', value: totalCount, color: 'blue' }, { 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' }, { label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
], ],
analyticsHref: '/account/usage', analyticsHref: '/account/usage',

View File

@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContentImages, fetchContentImages,
fetchImages,
ContentImagesGroup, ContentImagesGroup,
ContentImagesResponse, ContentImagesResponse,
fetchImageGenerationSettings, fetchImageGenerationSettings,
@@ -35,6 +36,12 @@ export default function Images() {
const [images, setImages] = useState<ContentImagesGroup[]>([]); const [images, setImages] = useState<ContentImagesGroup[]>([]);
const [loading, setLoading] = useState(true); 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 // Filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
@@ -69,6 +76,49 @@ export default function Images() {
const [isImageModalOpen, setIsImageModalOpen] = useState(false); const [isImageModalOpen, setIsImageModalOpen] = useState(false);
const [modalImageUrl, setModalImageUrl] = useState<string | null>(null); 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 // Load images - wrapped in useCallback
const loadImages = useCallback(async () => { const loadImages = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -155,18 +205,17 @@ export default function Images() {
}; };
}, [loadImages]); }, [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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (currentPage === 1) { // Always reset to page 1 when search changes
loadImages(); // The main useEffect will handle reloading when currentPage changes
} else {
setCurrentPage(1); setCurrentPage(1);
}
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadImages]); }, [searchTerm]);
// Handle sorting // Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => { const handleSort = (field: string, direction: 'asc' | 'desc') => {
@@ -438,16 +487,54 @@ export default function Images() {
}); });
}, [searchTerm, statusFilter, maxInArticleImages, handleGenerateImages, handleImageClick]); }, [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(() => { const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return []; if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
// 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, label: metric.label,
value: metric.calculate({ images, totalCount }), value,
accentColor: metric.accentColor, accentColor: metric.accentColor,
tooltip: (metric as any).tooltip, tooltip: (metric as any).tooltip,
})); };
}, [pageConfig?.headerMetrics, images, totalCount]); });
// 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 ( return (
<> <>
@@ -617,7 +704,7 @@ export default function Images() {
fromHref: '/writer/content', fromHref: '/writer/content',
actionLabel: 'Generate Images', actionLabel: 'Generate Images',
toLabel: 'Images', toLabel: 'Images',
toValue: totalCount, toValue: totalImagesCount,
toHref: '/writer/images', toHref: '/writer/images',
progress: 100, progress: 100,
color: 'purple', color: 'purple',
@@ -650,7 +737,7 @@ export default function Images() {
], ],
writerItems: [ writerItems: [
{ label: 'Content Generated', value: 0, color: 'blue' }, { 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' }, { label: 'Articles Published', value: 0, color: 'green' },
], ],
analyticsHref: '/account/usage', analyticsHref: '/account/usage',

View File

@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContent, fetchContent,
fetchImages,
Content, Content,
ContentListResponse, ContentListResponse,
ContentFilters, ContentFilters,
@@ -31,6 +32,7 @@ export default function Review() {
// Data state // Data state
const [content, setContent] = useState<Content[]>([]); const [content, setContent] = useState<Content[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state - default to review status // Filter state - default to review status
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -83,6 +85,19 @@ export default function Review() {
loadContent(); loadContent();
}, [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 // Listen for site and sector changes and refresh data
useEffect(() => { useEffect(() => {
const handleSiteChange = () => { const handleSiteChange = () => {
@@ -494,7 +509,7 @@ export default function Review() {
fromHref: '/writer/content', fromHref: '/writer/content',
actionLabel: 'Generate Images', actionLabel: 'Generate Images',
toLabel: 'Images', toLabel: 'Images',
toValue: 0, toValue: totalImagesCount,
toHref: '/writer/images', toHref: '/writer/images',
progress: 0, progress: 0,
color: 'purple', color: 'purple',
@@ -527,7 +542,7 @@ export default function Review() {
], ],
writerItems: [ writerItems: [
{ label: 'Content Generated', value: 0, color: 'blue' }, { 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' }, { label: 'Articles Published', value: 0, color: 'green' },
], ],
analyticsHref: '/account/usage', analyticsHref: '/account/usage',

View File

@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchTasks, fetchTasks,
fetchImages,
createTask, createTask,
updateTask, updateTask,
deleteTask, deleteTask,
@@ -43,6 +44,13 @@ export default function Tasks() {
const [clusters, setClusters] = useState<Cluster[]>([]); const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true); 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 // Filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
@@ -97,6 +105,54 @@ export default function Tasks() {
loadClusters(); 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 // Load tasks - wrapped in useCallback
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -167,18 +223,17 @@ export default function Tasks() {
}, [pageSize]); }, [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(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (currentPage === 1) { // Always reset to page 1 when search changes
loadTasks(); // The main useEffect will handle reloading when currentPage changes
} else {
setCurrentPage(1); setCurrentPage(1);
}
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadTasks]); }, [searchTerm]);
// Handle sorting // Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => { const handleSort = (field: string, direction: 'asc' | 'desc') => {
@@ -318,16 +373,47 @@ export default function Tasks() {
}); });
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, sourceFilter]); }, [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(() => { const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return []; if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
// 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, label: metric.label,
value: metric.calculate({ tasks, totalCount }), value,
accentColor: metric.accentColor, accentColor: metric.accentColor,
tooltip: (metric as any).tooltip, tooltip: (metric as any).tooltip,
})); };
}, [pageConfig?.headerMetrics, tasks, totalCount]); });
}, [pageConfig?.headerMetrics, tasks, totalCount, totalQueued, totalProcessing, totalCompleted, totalFailed]);
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
setFormData({ setFormData({
@@ -507,7 +593,7 @@ export default function Tasks() {
fromHref: '/writer/content', fromHref: '/writer/content',
actionLabel: 'Generate Images', actionLabel: 'Generate Images',
toLabel: 'Images', toLabel: 'Images',
toValue: 0, toValue: totalImagesCount,
toHref: '/writer/images', toHref: '/writer/images',
progress: 0, progress: 0,
color: 'purple', color: 'purple',
@@ -540,7 +626,7 @@ export default function Tasks() {
], ],
writerItems: [ writerItems: [
{ label: 'Content Generated', value: tasks.filter(t => t.status === 'completed').length, color: 'blue' }, { 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' }, { label: 'Published', value: 0, color: 'green' },
], ],
analyticsHref: '/account/usage', analyticsHref: '/account/usage',

View File

@@ -1042,3 +1042,90 @@ export async function getPublicPlans(): Promise<Plan[]> {
export async function getUsageSummary(): Promise<UsageSummary> { export async function getUsageSummary(): Promise<UsageSummary> {
return fetchAPI('/v1/billing/usage-summary/'); 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}` : ''}`);
}