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': [
('igny8_core_auth', 'Plan'),
('igny8_core_auth', 'Subscription'),
('billing', 'BillingConfiguration'),
('billing', 'Invoice'),
('billing', 'Payment'),
('billing', 'CreditPackage'),

View File

@@ -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'),
]

View File

@@ -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,
},
}
})

View File

@@ -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)),
]

View File

@@ -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()

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.
### 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

View File

@@ -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({

View File

@@ -11,15 +11,10 @@ import { useOnboardingStore } from "../../store/onboardingStore";
import { useBillingStore } from "../../store/billingStore";
import { GridIcon, PlusIcon } from "../../icons";
import {
fetchKeywords,
fetchClusters,
fetchContentIdeas,
fetchTasks,
fetchContent,
fetchContentImages,
fetchSites,
Site,
} from "../../services/api";
import { getDashboardStats } from "../../services/billing.api";
import { useSiteStore } from "../../store/siteStore";
import { useSectorStore } from "../../store/sectorStore";
import { useToast } from "../../components/ui/toast/ToastContainer";
@@ -155,49 +150,33 @@ export default function Home() {
const fetchDashboardData = useCallback(async () => {
try {
setLoading(true);
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
const siteId = siteFilter === 'all' ? undefined : siteFilter;
// Fetch pipeline counts sequentially to avoid rate limiting
const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId });
await delay(100);
const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId });
await delay(100);
const ideasRes = await fetchContentIdeas({ page_size: 1, site_id: siteId });
await delay(100);
const tasksRes = await fetchTasks({ page_size: 1, site_id: siteId });
await delay(100);
const contentRes = await fetchContent({ page_size: 1, site_id: siteId });
await delay(100);
const imagesRes = await fetchContentImages({ page_size: 1, site_id: siteId });
// Fetch real dashboard stats from API
const stats = await getDashboardStats({
site_id: siteId,
days: 7
});
const totalKeywords = keywordsRes.count || 0;
const totalClusters = clustersRes.count || 0;
const totalIdeas = ideasRes.count || 0;
const totalTasks = tasksRes.count || 0;
const totalContent = contentRes.count || 0;
const totalImages = imagesRes.count || 0;
const publishedContent = Math.floor(totalContent * 0.6); // Placeholder
// Calculate completion percentage
const completionPercentage = totalKeywords > 0
? Math.round((publishedContent / totalKeywords) * 100)
// Update pipeline data from real API data
const { pipeline, counts } = stats;
const completionPercentage = pipeline.keywords > 0
? Math.round((pipeline.published / pipeline.keywords) * 100)
: 0;
// Update pipeline data
setPipelineData({
sites: sites.length,
keywords: totalKeywords,
clusters: totalClusters,
ideas: totalIdeas,
tasks: totalTasks,
drafts: totalContent,
published: publishedContent,
sites: pipeline.sites,
keywords: pipeline.keywords,
clusters: pipeline.clusters,
ideas: pipeline.ideas,
tasks: pipeline.tasks,
drafts: pipeline.drafts,
published: pipeline.published,
completionPercentage: Math.min(completionPercentage, 100),
});
// Generate attention items based on data
// Generate attention items based on real data
const attentionList: AttentionItem[] = [];
// Check for sites without sectors
@@ -213,130 +192,85 @@ export default function Home() {
});
}
// Check for content needing images
const contentWithoutImages = totalContent - Math.floor(totalContent * 0.7);
if (contentWithoutImages > 0 && totalContent > 0) {
// Check for content needing images (content in review without all images generated)
const contentWithPendingImages = counts.images.pending;
if (contentWithPendingImages > 0) {
attentionList.push({
id: 'needs_images',
type: 'pending_review',
title: 'articles need images',
count: contentWithoutImages,
title: 'images pending',
count: contentWithPendingImages,
description: 'Generate images before publishing',
actionLabel: 'Generate Images',
actionHref: '/writer/images',
});
}
// Check for content in review
if (counts.content.review > 0) {
attentionList.push({
id: 'pending_review',
type: 'pending_review',
title: 'articles ready for review',
count: counts.content.review,
description: 'Review and publish content',
actionLabel: 'Review Content',
actionHref: '/writer/content?status=review',
});
}
setAttentionItems(attentionList);
// Update content velocity (using mock calculations based on totals)
const weeklyArticles = Math.floor(totalContent * 0.15);
const monthlyArticles = Math.floor(totalContent * 0.4);
setContentVelocity({
thisWeek: {
articles: weeklyArticles,
words: weeklyArticles * 1500,
images: Math.floor(totalImages * 0.15)
},
thisMonth: {
articles: monthlyArticles,
words: monthlyArticles * 1500,
images: Math.floor(totalImages * 0.4)
},
total: {
articles: totalContent,
words: totalContent * 1500,
images: totalImages
},
trend: totalContent > 0 ? Math.floor(Math.random() * 40) - 10 : 0,
});
// Update content velocity from real API data
setContentVelocity(stats.content_velocity);
// Generate mock recent activity based on actual data
const activityList: ActivityItem[] = [];
if (totalClusters > 0) {
activityList.push({
id: 'cluster_1',
type: 'clustering',
title: `Clustered ${Math.min(45, totalKeywords)} keywords → ${Math.min(8, totalClusters)} clusters`,
description: '',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
href: '/planner/clusters',
});
}
if (totalContent > 0) {
activityList.push({
id: 'content_1',
type: 'content',
title: `Generated ${Math.min(5, totalContent)} articles`,
description: '',
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000),
href: '/writer/content',
});
}
if (totalImages > 0) {
activityList.push({
id: 'images_1',
type: 'images',
title: `Created ${Math.min(15, totalImages)} image prompts`,
description: '',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
href: '/writer/images',
});
}
if (publishedContent > 0) {
activityList.push({
id: 'published_1',
type: 'published',
title: `Published article to WordPress`,
description: '',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
href: '/writer/published',
});
}
if (totalKeywords > 0) {
activityList.push({
id: 'keywords_1',
type: 'keywords',
title: `Added ${Math.min(23, totalKeywords)} keywords`,
description: '',
timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000),
href: '/planner/keywords',
});
}
// Update recent activity from real API data (convert timestamp strings to Date objects)
const activityList: ActivityItem[] = stats.recent_activity.map(item => ({
...item,
timestamp: new Date(item.timestamp),
}));
setRecentActivity(activityList);
// Update AI operations (mock data based on content created)
const clusteringOps = totalClusters > 0 ? Math.ceil(totalClusters / 3) : 0;
const ideasOps = totalIdeas > 0 ? Math.ceil(totalIdeas / 5) : 0;
const contentOps = totalContent;
const imageOps = totalImages > 0 ? Math.ceil(totalImages / 3) : 0;
// Update AI operations from real API data
// Map operation types to display types
const operationTypeMap: Record<string, string> = {
'clustering': 'clustering',
'idea_generation': 'ideas',
'content_generation': 'content',
'image_generation': 'images',
'image_prompt_extraction': 'images',
};
const mappedOperations = stats.ai_operations.operations.map(op => ({
type: operationTypeMap[op.type] || op.type,
count: op.count,
credits: op.credits,
}));
// Ensure all expected types exist
const expectedTypes = ['clustering', 'ideas', 'content', 'images'];
for (const type of expectedTypes) {
if (!mappedOperations.find(op => op.type === type)) {
mappedOperations.push({ type, count: 0, credits: 0 });
}
}
setAIOperations({
period: '7d',
operations: [
{ type: 'clustering', count: clusteringOps, credits: clusteringOps * 10 },
{ type: 'ideas', count: ideasOps, credits: ideasOps * 2 },
{ type: 'content', count: contentOps, credits: contentOps * 50 },
{ type: 'images', count: imageOps, credits: imageOps * 5 },
],
totals: {
count: clusteringOps + ideasOps + contentOps + imageOps,
credits: (clusteringOps * 10) + (ideasOps * 2) + (contentOps * 50) + (imageOps * 5),
successRate: 98.5,
avgCreditsPerOp: contentOps > 0 ? 18.6 : 0,
},
period: stats.ai_operations.period,
operations: mappedOperations,
totals: stats.ai_operations.totals,
});
// Set automation status (would come from API in real implementation)
// Set automation status (would come from automation API)
setAutomationData({
status: sites.length > 0 ? 'active' : 'not_configured',
schedule: sites.length > 0 ? 'Daily 9 AM' : undefined,
lastRun: sites.length > 0 ? {
lastRun: sites.length > 0 && counts.content.total > 0 ? {
timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000),
clustered: Math.min(12, totalKeywords),
ideas: Math.min(8, totalIdeas),
content: Math.min(5, totalContent),
images: Math.min(15, totalImages),
clustered: pipeline.clusters,
ideas: pipeline.ideas,
content: counts.content.total,
images: counts.images.total,
success: true,
} : undefined,
nextRun: sites.length > 0 ? new Date(Date.now() + 12 * 60 * 60 * 1000) : undefined,
@@ -352,7 +286,7 @@ export default function Home() {
} finally {
setLoading(false);
}
}, [siteFilter, sites.length, toast]);
}, [siteFilter, sites, toast]);
// Fetch dashboard data when filter changes
useEffect(() => {

View File

@@ -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',

View File

@@ -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',

View File

@@ -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,

View File

@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchContent,
fetchImages,
Content,
ContentListResponse,
ContentFilters,
@@ -35,6 +36,11 @@ export default function Approved() {
const [content, setContent] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
// Total counts for footer widget and header metrics (not page-filtered)
const [totalOnSite, setTotalOnSite] = useState(0);
const [totalPendingPublish, setTotalPendingPublish] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state - default to approved status
const [searchTerm, setSearchTerm] = useState('');
const [publishStatusFilter, setPublishStatusFilter] = useState('');
@@ -50,6 +56,36 @@ export default function Approved() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Load total metrics for footer widget and header metrics (not affected by pagination)
const loadTotalMetrics = useCallback(async () => {
try {
// Fetch all approved content to calculate totals
const data = await fetchContent({
status: 'published', // Backend uses 'published' for approved content
page_size: 1000, // Fetch enough to count
});
const allContent = data.results || [];
// Count by external_id presence
const onSite = allContent.filter(c => c.external_id).length;
const pending = allContent.filter(c => !c.external_id).length;
setTotalOnSite(onSite);
setTotalPendingPublish(pending);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, []);
// Load total metrics on mount
useEffect(() => {
loadTotalMetrics();
}, [loadTotalMetrics]);
// Load content - filtered for approved status (API still uses 'published' internally)
const loadContent = useCallback(async () => {
setLoading(true);
@@ -137,18 +173,17 @@ export default function Approved() {
setCurrentPage(1);
}, [pageSize]);
// Debounced search
// Debounced search - reset to page 1 when search term changes
// Only depend on searchTerm to avoid pagination reset on page navigation
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadContent();
} else {
setCurrentPage(1);
}
// Always reset to page 1 when search changes
// The main useEffect will handle reloading when currentPage changes
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadContent]);
}, [searchTerm]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
@@ -292,15 +327,38 @@ export default function Approved() {
});
}, [searchTerm, publishStatusFilter, activeSector, navigate]);
// Calculate header metrics
// Calculate header metrics - use totals from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ content, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, content, totalCount]);
// Override the calculate function to use pre-loaded totals instead of filtering page data
return pageConfig.headerMetrics.map((metric) => {
let value: number;
switch (metric.label) {
case 'Approved':
value = totalCount || 0;
break;
case 'On Site':
// Use totalOnSite from loadTotalMetrics()
value = totalOnSite;
break;
case 'Pending':
// Use totalPendingPublish from loadTotalMetrics()
value = totalPendingPublish;
break;
default:
value = metric.calculate({ content, totalCount });
}
return {
label: metric.label,
value,
accentColor: metric.accentColor,
};
});
}, [pageConfig?.headerMetrics, content, totalCount, totalOnSite, totalPendingPublish]);
return (
<>
@@ -398,7 +456,7 @@ export default function Approved() {
fromHref: '/writer/content',
actionLabel: 'Generate Images',
toLabel: 'Images',
toValue: 0,
toValue: totalImagesCount,
toHref: '/writer/images',
progress: 0,
color: 'purple',
@@ -431,7 +489,7 @@ export default function Approved() {
],
writerItems: [
{ label: 'Content Generated', value: 0, color: 'blue' },
{ label: 'Images Created', value: 0, color: 'purple' },
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
{ label: 'Articles Published', value: totalCount, color: 'green' },
],
analyticsHref: '/account/usage',

View File

@@ -8,6 +8,7 @@ import { Link, useNavigate } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchContent,
fetchImages,
Content as ContentType,
ContentFilters,
generateImagePrompts,
@@ -35,6 +36,12 @@ export default function Content() {
const [content, setContent] = useState<ContentType[]>([]);
const [loading, setLoading] = useState(true);
// Total counts for footer widget and header metrics (not page-filtered)
const [totalDraft, setTotalDraft] = useState(0);
const [totalReview, setTotalReview] = useState(0);
const [totalPublished, setTotalPublished] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('draft');
@@ -55,6 +62,46 @@ export default function Content() {
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load total metrics for footer widget and header metrics (not affected by pagination)
const loadTotalMetrics = useCallback(async () => {
try {
// Get content with status='draft'
const draftRes = await fetchContent({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'draft',
});
setTotalDraft(draftRes.count || 0);
// Get content with status='review'
const reviewRes = await fetchContent({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'review',
});
setTotalReview(reviewRes.count || 0);
// Get content with status='published'
const publishedRes = await fetchContent({
page_size: 1,
...(activeSector?.id && { sector_id: activeSector.id }),
status: 'published',
});
setTotalPublished(publishedRes.count || 0);
// Get actual total images count
const imagesRes = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesRes.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, [activeSector]);
// Load total metrics when sector changes
useEffect(() => {
loadTotalMetrics();
}, [loadTotalMetrics]);
// Load content - wrapped in useCallback
const loadContent = useCallback(async () => {
setLoading(true);
@@ -115,18 +162,17 @@ export default function Content() {
setCurrentPage(1);
}, [pageSize]);
// Debounced search
// Debounced search - reset to page 1 when search term changes
// Only depend on searchTerm to avoid pagination reset on page navigation
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadContent();
} else {
setCurrentPage(1);
}
// Always reset to page 1 when search changes
// The main useEffect will handle reloading when currentPage changes
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadContent]);
}, [searchTerm]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
@@ -160,16 +206,43 @@ export default function Content() {
handleRowClick,
]);
// Calculate header metrics
// Calculate header metrics - use totals from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ content, totalCount }),
accentColor: metric.accentColor,
tooltip: (metric as any).tooltip,
}));
}, [pageConfig?.headerMetrics, content, totalCount]);
// Override the calculate function to use pre-loaded totals instead of filtering page data
return pageConfig.headerMetrics.map((metric) => {
let value: number;
switch (metric.label) {
case 'Content':
value = totalCount || 0;
break;
case 'Draft':
// Use totalDraft from loadTotalMetrics()
value = totalDraft;
break;
case 'In Review':
// Use totalReview from loadTotalMetrics()
value = totalReview;
break;
case 'Published':
// Use totalPublished from loadTotalMetrics()
value = totalPublished;
break;
default:
value = metric.calculate({ content, totalCount });
}
return {
label: metric.label,
value,
accentColor: metric.accentColor,
tooltip: (metric as any).tooltip,
};
});
}, [pageConfig?.headerMetrics, content, totalCount, totalDraft, totalReview, totalPublished]);
const handleRowAction = useCallback(async (action: string, row: ContentType) => {
if (action === 'view_on_wordpress') {
@@ -347,7 +420,7 @@ export default function Content() {
],
writerItems: [
{ label: 'Content Generated', value: totalCount, color: 'blue' },
{ label: 'Images Created', value: content.filter(c => c.has_generated_images).length, color: 'purple' },
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
{ label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
],
analyticsHref: '/account/usage',

View File

@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchContentImages,
fetchImages,
ContentImagesGroup,
ContentImagesResponse,
fetchImageGenerationSettings,
@@ -35,6 +36,12 @@ export default function Images() {
const [images, setImages] = useState<ContentImagesGroup[]>([]);
const [loading, setLoading] = useState(true);
// Total counts for footer widget and header metrics (not page-filtered)
const [totalComplete, setTotalComplete] = useState(0);
const [totalPartial, setTotalPartial] = useState(0);
const [totalPending, setTotalPending] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0); // Actual images count
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -69,6 +76,49 @@ export default function Images() {
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
const [modalImageUrl, setModalImageUrl] = useState<string | null>(null);
// Load total metrics for footer widget and header metrics (not affected by pagination)
const loadTotalMetrics = useCallback(async () => {
try {
// Fetch content-grouped images for status counts
const data: ContentImagesResponse = await fetchContentImages({});
const allImages = data.results || [];
// Count by overall_status (content-level status)
let complete = 0;
let partial = 0;
let pending = 0;
allImages.forEach(img => {
switch (img.overall_status) {
case 'complete':
complete++;
break;
case 'partial':
partial++;
break;
case 'pending':
pending++;
break;
}
});
setTotalComplete(complete);
setTotalPartial(partial);
setTotalPending(pending);
// Fetch ACTUAL total images count from the images endpoint
const imagesData = await fetchImages({ page_size: 1 });
setTotalImagesCount(imagesData.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, []);
// Load total metrics on mount
useEffect(() => {
loadTotalMetrics();
}, [loadTotalMetrics]);
// Load images - wrapped in useCallback
const loadImages = useCallback(async () => {
setLoading(true);
@@ -155,18 +205,17 @@ export default function Images() {
};
}, [loadImages]);
// Debounced search
// Debounced search - reset to page 1 when search term changes
// Only depend on searchTerm to avoid pagination reset on page navigation
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadImages();
} else {
setCurrentPage(1);
}
// Always reset to page 1 when search changes
// The main useEffect will handle reloading when currentPage changes
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadImages]);
}, [searchTerm]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
@@ -438,16 +487,54 @@ export default function Images() {
});
}, [searchTerm, statusFilter, maxInArticleImages, handleGenerateImages, handleImageClick]);
// Calculate header metrics
// Calculate header metrics - use totals from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ images, totalCount }),
accentColor: metric.accentColor,
tooltip: (metric as any).tooltip,
}));
}, [pageConfig?.headerMetrics, images, totalCount]);
// Override the calculate function to use pre-loaded totals instead of filtering page data
// Also add a "Total Images" metric at the end
const baseMetrics = pageConfig.headerMetrics.map((metric) => {
let value: number;
switch (metric.label) {
case 'Content':
value = totalCount || 0;
break;
case 'Complete':
// Use totalComplete from loadTotalMetrics()
value = totalComplete;
break;
case 'Partial':
// Use totalPartial from loadTotalMetrics()
value = totalPartial;
break;
case 'Pending':
// Use totalPending from loadTotalMetrics()
value = totalPending;
break;
default:
value = metric.calculate({ images, totalCount });
}
return {
label: metric.label,
value,
accentColor: metric.accentColor,
tooltip: (metric as any).tooltip,
};
});
// Add total images count metric
baseMetrics.push({
label: 'Total Images',
value: totalImagesCount,
accentColor: 'purple' as const,
tooltip: 'Total number of images across all content',
});
return baseMetrics;
}, [pageConfig?.headerMetrics, images, totalCount, totalComplete, totalPartial, totalPending, totalImagesCount]);
return (
<>
@@ -617,7 +704,7 @@ export default function Images() {
fromHref: '/writer/content',
actionLabel: 'Generate Images',
toLabel: 'Images',
toValue: totalCount,
toValue: totalImagesCount,
toHref: '/writer/images',
progress: 100,
color: 'purple',
@@ -650,7 +737,7 @@ export default function Images() {
],
writerItems: [
{ label: 'Content Generated', value: 0, color: 'blue' },
{ label: 'Images Created', value: totalCount, color: 'purple' },
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
{ label: 'Articles Published', value: 0, color: 'green' },
],
analyticsHref: '/account/usage',

View File

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

View File

@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchTasks,
fetchImages,
createTask,
updateTask,
deleteTask,
@@ -43,6 +44,13 @@ export default function Tasks() {
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
// Total counts for footer widget and header metrics (not page-filtered)
const [totalQueued, setTotalQueued] = useState(0);
const [totalProcessing, setTotalProcessing] = useState(0);
const [totalCompleted, setTotalCompleted] = useState(0);
const [totalFailed, setTotalFailed] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = 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',

View File

@@ -1042,3 +1042,90 @@ export async function getPublicPlans(): Promise<Plan[]> {
export async function getUsageSummary(): Promise<UsageSummary> {
return fetchAPI('/v1/billing/usage-summary/');
}
// ============================================================================
// DASHBOARD STATS (Real data for home page)
// ============================================================================
export interface DashboardAIOperation {
type: string;
count: number;
credits: number;
}
export interface DashboardAIOperations {
period: string;
operations: DashboardAIOperation[];
totals: {
count: number;
credits: number;
successRate: number;
avgCreditsPerOp: number;
};
}
export interface DashboardActivityItem {
id: string;
type: string;
title: string;
description: string;
timestamp: string;
href: string;
}
export interface DashboardContentVelocity {
thisWeek: {
articles: number;
words: number;
images: number;
};
thisMonth: {
articles: number;
words: number;
images: number;
};
total: {
articles: number;
words: number;
images: number;
};
trend: number;
}
export interface DashboardPipeline {
sites: number;
keywords: number;
clusters: number;
ideas: number;
tasks: number;
drafts: number;
published: number;
}
export interface DashboardStats {
ai_operations: DashboardAIOperations;
recent_activity: DashboardActivityItem[];
content_velocity: DashboardContentVelocity;
pipeline: DashboardPipeline;
counts: {
content: {
total: number;
draft: number;
review: number;
published: number;
};
images: {
total: number;
generated: number;
pending: number;
};
};
}
export async function getDashboardStats(params?: { site_id?: number; days?: number }): Promise<DashboardStats> {
const searchParams = new URLSearchParams();
if (params?.site_id) searchParams.append('site_id', params.site_id.toString());
if (params?.days) searchParams.append('days', params.days.toString());
const query = searchParams.toString();
return fetchAPI(`/v1/account/dashboard/stats/${query ? `?${query}` : ''}`);
}