From 0ffd21b9bfda610eb464507b3bbec98429d1c973 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 29 Dec 2025 04:33:22 +0000 Subject: [PATCH] metricsa dn backedn fixes --- backend/igny8_core/admin/site.py | 1 + backend/igny8_core/api/account_urls.py | 8 +- backend/igny8_core/api/account_views.py | 214 +++++++++++++++++ backend/igny8_core/api/urls.py | 6 +- .../igny8_core/business/automation/views.py | 7 +- docs/fixes/footer-widget-pagination-fix.md | 2 +- .../src/pages/Automation/AutomationPage.tsx | 10 +- frontend/src/pages/Dashboard/Home.tsx | 220 ++++++------------ frontend/src/pages/Planner/Clusters.tsx | 64 +++-- frontend/src/pages/Planner/Ideas.tsx | 64 +++-- frontend/src/pages/Planner/Keywords.tsx | 51 +++- frontend/src/pages/Writer/Approved.tsx | 92 ++++++-- frontend/src/pages/Writer/Content.tsx | 107 +++++++-- frontend/src/pages/Writer/Images.tsx | 123 ++++++++-- frontend/src/pages/Writer/Review.tsx | 19 +- frontend/src/pages/Writer/Tasks.tsx | 120 ++++++++-- frontend/src/services/billing.api.ts | 87 +++++++ 17 files changed, 929 insertions(+), 266 deletions(-) diff --git a/backend/igny8_core/admin/site.py b/backend/igny8_core/admin/site.py index 166e5626..9055111f 100644 --- a/backend/igny8_core/admin/site.py +++ b/backend/igny8_core/admin/site.py @@ -170,6 +170,7 @@ class Igny8AdminSite(UnfoldAdminSite): 'models': [ ('igny8_core_auth', 'Plan'), ('igny8_core_auth', 'Subscription'), + ('billing', 'BillingConfiguration'), ('billing', 'Invoice'), ('billing', 'Payment'), ('billing', 'CreditPackage'), diff --git a/backend/igny8_core/api/account_urls.py b/backend/igny8_core/api/account_urls.py index 886ce0d8..4a25daf0 100644 --- a/backend/igny8_core/api/account_urls.py +++ b/backend/igny8_core/api/account_urls.py @@ -5,7 +5,8 @@ from django.urls import path from igny8_core.api.account_views import ( AccountSettingsViewSet, TeamManagementViewSet, - UsageAnalyticsViewSet + UsageAnalyticsViewSet, + DashboardStatsViewSet ) urlpatterns = [ @@ -28,4 +29,9 @@ urlpatterns = [ 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'), ] diff --git a/backend/igny8_core/api/account_views.py b/backend/igny8_core/api/account_views.py index 2b077e17..2edfe71d 100644 --- a/backend/igny8_core/api/account_views.py +++ b/backend/igny8_core/api/account_views.py @@ -10,6 +10,7 @@ from django.contrib.auth import get_user_model from django.db.models import Q, Count, Sum from django.utils import timezone from datetime import timedelta +from decimal import Decimal from drf_spectacular.utils import extend_schema, extend_schema_view 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_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, + }, + } + }) diff --git a/backend/igny8_core/api/urls.py b/backend/igny8_core/api/urls.py index 7118ca9a..62a1099d 100644 --- a/backend/igny8_core/api/urls.py +++ b/backend/igny8_core/api/urls.py @@ -6,7 +6,8 @@ from rest_framework.routers import DefaultRouter from .account_views import ( AccountSettingsViewSet, TeamManagementViewSet, - UsageAnalyticsViewSet + UsageAnalyticsViewSet, + DashboardStatsViewSet ) router = DefaultRouter() @@ -22,5 +23,8 @@ urlpatterns = [ # 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)), ] diff --git a/backend/igny8_core/business/automation/views.py b/backend/igny8_core/business/automation/views.py index 00d09c7e..c9aa8995 100644 --- a/backend/igny8_core/business/automation/views.py +++ b/backend/igny8_core/business/automation/views.py @@ -387,16 +387,17 @@ class AutomationViewSet(viewsets.ViewSet): 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( Keywords, 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( site=site, status='new', - cluster__isnull=True, disabled=False ).count() diff --git a/docs/fixes/footer-widget-pagination-fix.md b/docs/fixes/footer-widget-pagination-fix.md index e095691f..21f22317 100644 --- a/docs/fixes/footer-widget-pagination-fix.md +++ b/docs/fixes/footer-widget-pagination-fix.md @@ -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. -### Example: +### Example:s - 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` - The footer shows "Unmapped: 5" instead of the actual total unmapped count diff --git a/frontend/src/pages/Automation/AutomationPage.tsx b/frontend/src/pages/Automation/AutomationPage.tsx index 633d500a..0e8cf0e7 100644 --- a/frontend/src/pages/Automation/AutomationPage.tsx +++ b/frontend/src/pages/Automation/AutomationPage.tsx @@ -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({ diff --git a/frontend/src/pages/Dashboard/Home.tsx b/frontend/src/pages/Dashboard/Home.tsx index 6a8c84f9..9edaf87a 100644 --- a/frontend/src/pages/Dashboard/Home.tsx +++ b/frontend/src/pages/Dashboard/Home.tsx @@ -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 = { + '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(() => { diff --git a/frontend/src/pages/Planner/Clusters.tsx b/frontend/src/pages/Planner/Clusters.tsx index 8a5c7a6d..5f128b13 100644 --- a/frontend/src/pages/Planner/Clusters.tsx +++ b/frontend/src/pages/Planner/Clusters.tsx @@ -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', diff --git a/frontend/src/pages/Planner/Ideas.tsx b/frontend/src/pages/Planner/Ideas.tsx index 450b36b2..9f81dd52 100644 --- a/frontend/src/pages/Planner/Ideas.tsx +++ b/frontend/src/pages/Planner/Ideas.tsx @@ -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', diff --git a/frontend/src/pages/Planner/Keywords.tsx b/frontend/src/pages/Planner/Keywords.tsx index 6f731f1a..05b4a9ad 100644 --- a/frontend/src/pages/Planner/Keywords.tsx +++ b/frontend/src/pages/Planner/Keywords.tsx @@ -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, diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx index e67a1769..8af29d28 100644 --- a/frontend/src/pages/Writer/Approved.tsx +++ b/frontend/src/pages/Writer/Approved.tsx @@ -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([]); 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', diff --git a/frontend/src/pages/Writer/Content.tsx b/frontend/src/pages/Writer/Content.tsx index 6d46ba59..aad6cb9e 100644 --- a/frontend/src/pages/Writer/Content.tsx +++ b/frontend/src/pages/Writer/Content.tsx @@ -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([]); 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', diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx index af895794..34ce9e35 100644 --- a/frontend/src/pages/Writer/Images.tsx +++ b/frontend/src/pages/Writer/Images.tsx @@ -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([]); 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(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', diff --git a/frontend/src/pages/Writer/Review.tsx b/frontend/src/pages/Writer/Review.tsx index 0bfefe6e..720da34f 100644 --- a/frontend/src/pages/Writer/Review.tsx +++ b/frontend/src/pages/Writer/Review.tsx @@ -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([]); 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', diff --git a/frontend/src/pages/Writer/Tasks.tsx b/frontend/src/pages/Writer/Tasks.tsx index 86b9ada1..b0f0ea85 100644 --- a/frontend/src/pages/Writer/Tasks.tsx +++ b/frontend/src/pages/Writer/Tasks.tsx @@ -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([]); const [clusters, setClusters] = useState([]); 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', diff --git a/frontend/src/services/billing.api.ts b/frontend/src/services/billing.api.ts index 08ed52f9..df2108a3 100644 --- a/frontend/src/services/billing.api.ts +++ b/frontend/src/services/billing.api.ts @@ -1042,3 +1042,90 @@ export async function getPublicPlans(): Promise { export async function getUsageSummary(): Promise { 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 { + 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}` : ''}`); +}