metricsa dn backedn fixes
This commit is contained in:
@@ -170,6 +170,7 @@ class Igny8AdminSite(UnfoldAdminSite):
|
|||||||
'models': [
|
'models': [
|
||||||
('igny8_core_auth', 'Plan'),
|
('igny8_core_auth', 'Plan'),
|
||||||
('igny8_core_auth', 'Subscription'),
|
('igny8_core_auth', 'Subscription'),
|
||||||
|
('billing', 'BillingConfiguration'),
|
||||||
('billing', 'Invoice'),
|
('billing', 'Invoice'),
|
||||||
('billing', 'Payment'),
|
('billing', 'Payment'),
|
||||||
('billing', 'CreditPackage'),
|
('billing', 'CreditPackage'),
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from django.urls import path
|
|||||||
from igny8_core.api.account_views import (
|
from igny8_core.api.account_views import (
|
||||||
AccountSettingsViewSet,
|
AccountSettingsViewSet,
|
||||||
TeamManagementViewSet,
|
TeamManagementViewSet,
|
||||||
UsageAnalyticsViewSet
|
UsageAnalyticsViewSet,
|
||||||
|
DashboardStatsViewSet
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -28,4 +29,9 @@ urlpatterns = [
|
|||||||
path('usage/analytics/', UsageAnalyticsViewSet.as_view({
|
path('usage/analytics/', UsageAnalyticsViewSet.as_view({
|
||||||
'get': 'overview'
|
'get': 'overview'
|
||||||
}), name='usage-analytics'),
|
}), name='usage-analytics'),
|
||||||
|
|
||||||
|
# Dashboard Stats (real data for home page)
|
||||||
|
path('dashboard/stats/', DashboardStatsViewSet.as_view({
|
||||||
|
'get': 'stats'
|
||||||
|
}), name='dashboard-stats'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.db.models import Q, Count, Sum
|
from django.db.models import Q, Count, Sum
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
|
||||||
from igny8_core.auth.models import Account
|
from igny8_core.auth.models import Account
|
||||||
@@ -242,3 +243,216 @@ class UsageAnalyticsViewSet(viewsets.ViewSet):
|
|||||||
'total_usage': abs(transactions.filter(amount__lt=0).aggregate(Sum('amount'))['amount__sum'] or 0),
|
'total_usage': abs(transactions.filter(amount__lt=0).aggregate(Sum('amount'))['amount__sum'] or 0),
|
||||||
'total_purchases': transactions.filter(amount__gt=0).aggregate(Sum('amount'))['amount__sum'] or 0,
|
'total_purchases': transactions.filter(amount__gt=0).aggregate(Sum('amount'))['amount__sum'] or 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
stats=extend_schema(tags=['Account']),
|
||||||
|
)
|
||||||
|
class DashboardStatsViewSet(viewsets.ViewSet):
|
||||||
|
"""Dashboard statistics - real data for home page widgets"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def stats(self, request):
|
||||||
|
"""
|
||||||
|
Get dashboard statistics for the home page.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- site_id: Filter by site (optional, defaults to all sites)
|
||||||
|
- days: Number of days for AI operations (default: 7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- ai_operations: Real credit usage by operation type
|
||||||
|
- recent_activity: Recent notifications
|
||||||
|
- content_velocity: Content created this week/month
|
||||||
|
- images_count: Actual total images count
|
||||||
|
- published_count: Actual published content count
|
||||||
|
"""
|
||||||
|
account = request.user.account
|
||||||
|
site_id = request.query_params.get('site_id')
|
||||||
|
days = int(request.query_params.get('days', 7))
|
||||||
|
|
||||||
|
# Import models here to avoid circular imports
|
||||||
|
from igny8_core.modules.writer.models import Images, Content
|
||||||
|
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||||
|
from igny8_core.business.notifications.models import Notification
|
||||||
|
from igny8_core.business.billing.models import CreditUsageLog
|
||||||
|
from igny8_core.auth.models import Site
|
||||||
|
|
||||||
|
# Build base filter for site
|
||||||
|
site_filter = {}
|
||||||
|
if site_id:
|
||||||
|
try:
|
||||||
|
site_filter['site_id'] = int(site_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ========== AI OPERATIONS (from CreditUsageLog) ==========
|
||||||
|
start_date = timezone.now() - timedelta(days=days)
|
||||||
|
usage_query = CreditUsageLog.objects.filter(
|
||||||
|
account=account,
|
||||||
|
created_at__gte=start_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get operations grouped by type
|
||||||
|
operations_data = usage_query.values('operation_type').annotate(
|
||||||
|
count=Count('id'),
|
||||||
|
credits=Sum('credits_used')
|
||||||
|
).order_by('-credits')
|
||||||
|
|
||||||
|
# Calculate totals
|
||||||
|
total_ops = usage_query.count()
|
||||||
|
total_credits = usage_query.aggregate(total=Sum('credits_used'))['total'] or 0
|
||||||
|
|
||||||
|
# Format operations for frontend
|
||||||
|
operations = []
|
||||||
|
for op in operations_data:
|
||||||
|
op_type = op['operation_type'] or 'other'
|
||||||
|
operations.append({
|
||||||
|
'type': op_type,
|
||||||
|
'count': op['count'] or 0,
|
||||||
|
'credits': op['credits'] or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
ai_operations = {
|
||||||
|
'period': f'{days}d',
|
||||||
|
'operations': operations,
|
||||||
|
'totals': {
|
||||||
|
'count': total_ops,
|
||||||
|
'credits': total_credits,
|
||||||
|
'successRate': 98.5, # TODO: calculate from actual success/failure
|
||||||
|
'avgCreditsPerOp': round(total_credits / total_ops, 1) if total_ops > 0 else 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== RECENT ACTIVITY (from Notifications) ==========
|
||||||
|
recent_notifications = Notification.objects.filter(
|
||||||
|
account=account
|
||||||
|
).order_by('-created_at')[:10]
|
||||||
|
|
||||||
|
recent_activity = []
|
||||||
|
for notif in recent_notifications:
|
||||||
|
# Map notification type to activity type
|
||||||
|
activity_type_map = {
|
||||||
|
'ai_clustering_complete': 'clustering',
|
||||||
|
'ai_ideas_complete': 'ideas',
|
||||||
|
'ai_content_complete': 'content',
|
||||||
|
'ai_images_complete': 'images',
|
||||||
|
'ai_prompts_complete': 'images',
|
||||||
|
'content_published': 'published',
|
||||||
|
'wp_sync_success': 'published',
|
||||||
|
}
|
||||||
|
activity_type = activity_type_map.get(notif.notification_type, 'system')
|
||||||
|
|
||||||
|
# Map notification type to href
|
||||||
|
href_map = {
|
||||||
|
'clustering': '/planner/clusters',
|
||||||
|
'ideas': '/planner/ideas',
|
||||||
|
'content': '/writer/content',
|
||||||
|
'images': '/writer/images',
|
||||||
|
'published': '/writer/published',
|
||||||
|
}
|
||||||
|
|
||||||
|
recent_activity.append({
|
||||||
|
'id': str(notif.id),
|
||||||
|
'type': activity_type,
|
||||||
|
'title': notif.title,
|
||||||
|
'description': notif.message[:100] if notif.message else '',
|
||||||
|
'timestamp': notif.created_at.isoformat(),
|
||||||
|
'href': href_map.get(activity_type, '/dashboard'),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ========== CONTENT COUNTS ==========
|
||||||
|
content_base = Content.objects.filter(account=account)
|
||||||
|
if site_filter:
|
||||||
|
content_base = content_base.filter(**site_filter)
|
||||||
|
|
||||||
|
total_content = content_base.count()
|
||||||
|
draft_content = content_base.filter(status='draft').count()
|
||||||
|
review_content = content_base.filter(status='review').count()
|
||||||
|
published_content = content_base.filter(status='published').count()
|
||||||
|
|
||||||
|
# ========== IMAGES COUNT (actual images, not content with images) ==========
|
||||||
|
images_base = Images.objects.filter(account=account)
|
||||||
|
if site_filter:
|
||||||
|
images_base = images_base.filter(**site_filter)
|
||||||
|
|
||||||
|
total_images = images_base.count()
|
||||||
|
generated_images = images_base.filter(status='generated').count()
|
||||||
|
pending_images = images_base.filter(status='pending').count()
|
||||||
|
|
||||||
|
# ========== CONTENT VELOCITY ==========
|
||||||
|
now = timezone.now()
|
||||||
|
week_ago = now - timedelta(days=7)
|
||||||
|
month_ago = now - timedelta(days=30)
|
||||||
|
|
||||||
|
# This week's content
|
||||||
|
week_content = content_base.filter(created_at__gte=week_ago).count()
|
||||||
|
week_images = images_base.filter(created_at__gte=week_ago).count()
|
||||||
|
|
||||||
|
# This month's content
|
||||||
|
month_content = content_base.filter(created_at__gte=month_ago).count()
|
||||||
|
month_images = images_base.filter(created_at__gte=month_ago).count()
|
||||||
|
|
||||||
|
# Estimate words (avg 1500 per article)
|
||||||
|
content_velocity = {
|
||||||
|
'thisWeek': {
|
||||||
|
'articles': week_content,
|
||||||
|
'words': week_content * 1500,
|
||||||
|
'images': week_images,
|
||||||
|
},
|
||||||
|
'thisMonth': {
|
||||||
|
'articles': month_content,
|
||||||
|
'words': month_content * 1500,
|
||||||
|
'images': month_images,
|
||||||
|
},
|
||||||
|
'total': {
|
||||||
|
'articles': total_content,
|
||||||
|
'words': total_content * 1500,
|
||||||
|
'images': total_images,
|
||||||
|
},
|
||||||
|
'trend': 0, # TODO: calculate actual trend
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== PIPELINE COUNTS ==========
|
||||||
|
keywords_base = Keywords.objects.filter(account=account)
|
||||||
|
clusters_base = Clusters.objects.filter(account=account)
|
||||||
|
ideas_base = ContentIdeas.objects.filter(account=account)
|
||||||
|
|
||||||
|
if site_filter:
|
||||||
|
keywords_base = keywords_base.filter(**site_filter)
|
||||||
|
clusters_base = clusters_base.filter(**site_filter)
|
||||||
|
ideas_base = ideas_base.filter(**site_filter)
|
||||||
|
|
||||||
|
# Get site count
|
||||||
|
sites_count = Site.objects.filter(account=account, is_active=True).count()
|
||||||
|
|
||||||
|
pipeline = {
|
||||||
|
'sites': sites_count,
|
||||||
|
'keywords': keywords_base.count(),
|
||||||
|
'clusters': clusters_base.count(),
|
||||||
|
'ideas': ideas_base.count(),
|
||||||
|
'tasks': ideas_base.filter(status='queued').count() + ideas_base.filter(status='completed').count(),
|
||||||
|
'drafts': draft_content + review_content,
|
||||||
|
'published': published_content,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'ai_operations': ai_operations,
|
||||||
|
'recent_activity': recent_activity,
|
||||||
|
'content_velocity': content_velocity,
|
||||||
|
'pipeline': pipeline,
|
||||||
|
'counts': {
|
||||||
|
'content': {
|
||||||
|
'total': total_content,
|
||||||
|
'draft': draft_content,
|
||||||
|
'review': review_content,
|
||||||
|
'published': published_content,
|
||||||
|
},
|
||||||
|
'images': {
|
||||||
|
'total': total_images,
|
||||||
|
'generated': generated_images,
|
||||||
|
'pending': pending_images,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from rest_framework.routers import DefaultRouter
|
|||||||
from .account_views import (
|
from .account_views import (
|
||||||
AccountSettingsViewSet,
|
AccountSettingsViewSet,
|
||||||
TeamManagementViewSet,
|
TeamManagementViewSet,
|
||||||
UsageAnalyticsViewSet
|
UsageAnalyticsViewSet,
|
||||||
|
DashboardStatsViewSet
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -22,5 +23,8 @@ urlpatterns = [
|
|||||||
# Usage analytics
|
# Usage analytics
|
||||||
path('usage/analytics/', UsageAnalyticsViewSet.as_view({'get': 'overview'}), name='usage-analytics'),
|
path('usage/analytics/', UsageAnalyticsViewSet.as_view({'get': 'overview'}), name='usage-analytics'),
|
||||||
|
|
||||||
|
# Dashboard stats (real data for home page)
|
||||||
|
path('dashboard/stats/', DashboardStatsViewSet.as_view({'get': 'stats'}), name='dashboard-stats'),
|
||||||
|
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -387,16 +387,17 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
return counts, total
|
return counts, total
|
||||||
|
|
||||||
# Stage 1: Keywords pending clustering (keep previous "pending" semantics but also return status breakdown)
|
# Stage 1: Keywords pending clustering
|
||||||
stage_1_counts, stage_1_total = _counts_by_status(
|
stage_1_counts, stage_1_total = _counts_by_status(
|
||||||
Keywords,
|
Keywords,
|
||||||
extra_filter={'disabled': False}
|
extra_filter={'disabled': False}
|
||||||
)
|
)
|
||||||
# pending definition used by the UI previously (new & not clustered)
|
# FIXED: Stage 1 pending = all keywords with status='new' (ready for clustering)
|
||||||
|
# This should match the "New" count shown in Keywords metric card
|
||||||
|
# Previously filtered by cluster__isnull=True which caused mismatch
|
||||||
stage_1_pending = Keywords.objects.filter(
|
stage_1_pending = Keywords.objects.filter(
|
||||||
site=site,
|
site=site,
|
||||||
status='new',
|
status='new',
|
||||||
cluster__isnull=True,
|
|
||||||
disabled=False
|
disabled=False
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
All pages with `ThreeWidgetFooter` are calculating metrics using **page-filtered arrays** instead of **total counts** from the API. This causes incorrect metric values when users are viewing paginated results.
|
All pages with `ThreeWidgetFooter` are calculating metrics using **page-filtered arrays** instead of **total counts** from the API. This causes incorrect metric values when users are viewing paginated results.
|
||||||
|
|
||||||
### Example:
|
### Example:s
|
||||||
- If there are 100 total keywords with 10 on the current page
|
- If there are 100 total keywords with 10 on the current page
|
||||||
- And 5 keywords on the current page don't have a `cluster_id`
|
- And 5 keywords on the current page don't have a `cluster_id`
|
||||||
- The footer shows "Unmapped: 5" instead of the actual total unmapped count
|
- The footer shows "Unmapped: 5" instead of the actual total unmapped count
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
fetchContentIdeas,
|
fetchContentIdeas,
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
fetchContent,
|
fetchContent,
|
||||||
fetchContentImages,
|
fetchImages,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import ActivityLog from '../../components/Automation/ActivityLog';
|
import ActivityLog from '../../components/Automation/ActivityLog';
|
||||||
import ConfigModal from '../../components/Automation/ConfigModal';
|
import ConfigModal from '../../components/Automation/ConfigModal';
|
||||||
@@ -130,8 +130,8 @@ const AutomationPage: React.FC = () => {
|
|||||||
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||||||
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||||||
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||||||
fetchContentImages({ page_size: 1, site_id: siteId }),
|
fetchImages({ page_size: 1 }),
|
||||||
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
fetchImages({ page_size: 1, status: 'pending' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setMetrics({
|
setMetrics({
|
||||||
@@ -249,8 +249,8 @@ const AutomationPage: React.FC = () => {
|
|||||||
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||||||
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||||||
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||||||
fetchContentImages({ page_size: 1, site_id: siteId }),
|
fetchImages({ page_size: 1 }),
|
||||||
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
fetchImages({ page_size: 1, status: 'pending' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setMetrics({
|
setMetrics({
|
||||||
|
|||||||
@@ -11,15 +11,10 @@ import { useOnboardingStore } from "../../store/onboardingStore";
|
|||||||
import { useBillingStore } from "../../store/billingStore";
|
import { useBillingStore } from "../../store/billingStore";
|
||||||
import { GridIcon, PlusIcon } from "../../icons";
|
import { GridIcon, PlusIcon } from "../../icons";
|
||||||
import {
|
import {
|
||||||
fetchKeywords,
|
|
||||||
fetchClusters,
|
|
||||||
fetchContentIdeas,
|
|
||||||
fetchTasks,
|
|
||||||
fetchContent,
|
|
||||||
fetchContentImages,
|
|
||||||
fetchSites,
|
fetchSites,
|
||||||
Site,
|
Site,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
import { getDashboardStats } from "../../services/billing.api";
|
||||||
import { useSiteStore } from "../../store/siteStore";
|
import { useSiteStore } from "../../store/siteStore";
|
||||||
import { useSectorStore } from "../../store/sectorStore";
|
import { useSectorStore } from "../../store/sectorStore";
|
||||||
import { useToast } from "../../components/ui/toast/ToastContainer";
|
import { useToast } from "../../components/ui/toast/ToastContainer";
|
||||||
@@ -155,49 +150,33 @@ export default function Home() {
|
|||||||
const fetchDashboardData = useCallback(async () => {
|
const fetchDashboardData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
|
||||||
|
|
||||||
const siteId = siteFilter === 'all' ? undefined : siteFilter;
|
const siteId = siteFilter === 'all' ? undefined : siteFilter;
|
||||||
|
|
||||||
// Fetch pipeline counts sequentially to avoid rate limiting
|
// Fetch real dashboard stats from API
|
||||||
const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId });
|
const stats = await getDashboardStats({
|
||||||
await delay(100);
|
site_id: siteId,
|
||||||
const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId });
|
days: 7
|
||||||
await delay(100);
|
});
|
||||||
const ideasRes = await fetchContentIdeas({ page_size: 1, site_id: siteId });
|
|
||||||
await delay(100);
|
|
||||||
const tasksRes = await fetchTasks({ page_size: 1, site_id: siteId });
|
|
||||||
await delay(100);
|
|
||||||
const contentRes = await fetchContent({ page_size: 1, site_id: siteId });
|
|
||||||
await delay(100);
|
|
||||||
const imagesRes = await fetchContentImages({ page_size: 1, site_id: siteId });
|
|
||||||
|
|
||||||
const totalKeywords = keywordsRes.count || 0;
|
// Update pipeline data from real API data
|
||||||
const totalClusters = clustersRes.count || 0;
|
const { pipeline, counts } = stats;
|
||||||
const totalIdeas = ideasRes.count || 0;
|
const completionPercentage = pipeline.keywords > 0
|
||||||
const totalTasks = tasksRes.count || 0;
|
? Math.round((pipeline.published / pipeline.keywords) * 100)
|
||||||
const totalContent = contentRes.count || 0;
|
|
||||||
const totalImages = imagesRes.count || 0;
|
|
||||||
const publishedContent = Math.floor(totalContent * 0.6); // Placeholder
|
|
||||||
|
|
||||||
// Calculate completion percentage
|
|
||||||
const completionPercentage = totalKeywords > 0
|
|
||||||
? Math.round((publishedContent / totalKeywords) * 100)
|
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Update pipeline data
|
|
||||||
setPipelineData({
|
setPipelineData({
|
||||||
sites: sites.length,
|
sites: pipeline.sites,
|
||||||
keywords: totalKeywords,
|
keywords: pipeline.keywords,
|
||||||
clusters: totalClusters,
|
clusters: pipeline.clusters,
|
||||||
ideas: totalIdeas,
|
ideas: pipeline.ideas,
|
||||||
tasks: totalTasks,
|
tasks: pipeline.tasks,
|
||||||
drafts: totalContent,
|
drafts: pipeline.drafts,
|
||||||
published: publishedContent,
|
published: pipeline.published,
|
||||||
completionPercentage: Math.min(completionPercentage, 100),
|
completionPercentage: Math.min(completionPercentage, 100),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate attention items based on data
|
// Generate attention items based on real data
|
||||||
const attentionList: AttentionItem[] = [];
|
const attentionList: AttentionItem[] = [];
|
||||||
|
|
||||||
// Check for sites without sectors
|
// Check for sites without sectors
|
||||||
@@ -213,130 +192,85 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for content needing images
|
// Check for content needing images (content in review without all images generated)
|
||||||
const contentWithoutImages = totalContent - Math.floor(totalContent * 0.7);
|
const contentWithPendingImages = counts.images.pending;
|
||||||
if (contentWithoutImages > 0 && totalContent > 0) {
|
if (contentWithPendingImages > 0) {
|
||||||
attentionList.push({
|
attentionList.push({
|
||||||
id: 'needs_images',
|
id: 'needs_images',
|
||||||
type: 'pending_review',
|
type: 'pending_review',
|
||||||
title: 'articles need images',
|
title: 'images pending',
|
||||||
count: contentWithoutImages,
|
count: contentWithPendingImages,
|
||||||
description: 'Generate images before publishing',
|
description: 'Generate images before publishing',
|
||||||
actionLabel: 'Generate Images',
|
actionLabel: 'Generate Images',
|
||||||
actionHref: '/writer/images',
|
actionHref: '/writer/images',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for content in review
|
||||||
|
if (counts.content.review > 0) {
|
||||||
|
attentionList.push({
|
||||||
|
id: 'pending_review',
|
||||||
|
type: 'pending_review',
|
||||||
|
title: 'articles ready for review',
|
||||||
|
count: counts.content.review,
|
||||||
|
description: 'Review and publish content',
|
||||||
|
actionLabel: 'Review Content',
|
||||||
|
actionHref: '/writer/content?status=review',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setAttentionItems(attentionList);
|
setAttentionItems(attentionList);
|
||||||
|
|
||||||
// Update content velocity (using mock calculations based on totals)
|
// Update content velocity from real API data
|
||||||
const weeklyArticles = Math.floor(totalContent * 0.15);
|
setContentVelocity(stats.content_velocity);
|
||||||
const monthlyArticles = Math.floor(totalContent * 0.4);
|
|
||||||
setContentVelocity({
|
|
||||||
thisWeek: {
|
|
||||||
articles: weeklyArticles,
|
|
||||||
words: weeklyArticles * 1500,
|
|
||||||
images: Math.floor(totalImages * 0.15)
|
|
||||||
},
|
|
||||||
thisMonth: {
|
|
||||||
articles: monthlyArticles,
|
|
||||||
words: monthlyArticles * 1500,
|
|
||||||
images: Math.floor(totalImages * 0.4)
|
|
||||||
},
|
|
||||||
total: {
|
|
||||||
articles: totalContent,
|
|
||||||
words: totalContent * 1500,
|
|
||||||
images: totalImages
|
|
||||||
},
|
|
||||||
trend: totalContent > 0 ? Math.floor(Math.random() * 40) - 10 : 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate mock recent activity based on actual data
|
// Update recent activity from real API data (convert timestamp strings to Date objects)
|
||||||
const activityList: ActivityItem[] = [];
|
const activityList: ActivityItem[] = stats.recent_activity.map(item => ({
|
||||||
if (totalClusters > 0) {
|
...item,
|
||||||
activityList.push({
|
timestamp: new Date(item.timestamp),
|
||||||
id: 'cluster_1',
|
}));
|
||||||
type: 'clustering',
|
|
||||||
title: `Clustered ${Math.min(45, totalKeywords)} keywords → ${Math.min(8, totalClusters)} clusters`,
|
|
||||||
description: '',
|
|
||||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
|
||||||
href: '/planner/clusters',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (totalContent > 0) {
|
|
||||||
activityList.push({
|
|
||||||
id: 'content_1',
|
|
||||||
type: 'content',
|
|
||||||
title: `Generated ${Math.min(5, totalContent)} articles`,
|
|
||||||
description: '',
|
|
||||||
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000),
|
|
||||||
href: '/writer/content',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (totalImages > 0) {
|
|
||||||
activityList.push({
|
|
||||||
id: 'images_1',
|
|
||||||
type: 'images',
|
|
||||||
title: `Created ${Math.min(15, totalImages)} image prompts`,
|
|
||||||
description: '',
|
|
||||||
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
|
||||||
href: '/writer/images',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (publishedContent > 0) {
|
|
||||||
activityList.push({
|
|
||||||
id: 'published_1',
|
|
||||||
type: 'published',
|
|
||||||
title: `Published article to WordPress`,
|
|
||||||
description: '',
|
|
||||||
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
|
||||||
href: '/writer/published',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (totalKeywords > 0) {
|
|
||||||
activityList.push({
|
|
||||||
id: 'keywords_1',
|
|
||||||
type: 'keywords',
|
|
||||||
title: `Added ${Math.min(23, totalKeywords)} keywords`,
|
|
||||||
description: '',
|
|
||||||
timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000),
|
|
||||||
href: '/planner/keywords',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setRecentActivity(activityList);
|
setRecentActivity(activityList);
|
||||||
|
|
||||||
// Update AI operations (mock data based on content created)
|
// Update AI operations from real API data
|
||||||
const clusteringOps = totalClusters > 0 ? Math.ceil(totalClusters / 3) : 0;
|
// Map operation types to display types
|
||||||
const ideasOps = totalIdeas > 0 ? Math.ceil(totalIdeas / 5) : 0;
|
const operationTypeMap: Record<string, string> = {
|
||||||
const contentOps = totalContent;
|
'clustering': 'clustering',
|
||||||
const imageOps = totalImages > 0 ? Math.ceil(totalImages / 3) : 0;
|
'idea_generation': 'ideas',
|
||||||
|
'content_generation': 'content',
|
||||||
|
'image_generation': 'images',
|
||||||
|
'image_prompt_extraction': 'images',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedOperations = stats.ai_operations.operations.map(op => ({
|
||||||
|
type: operationTypeMap[op.type] || op.type,
|
||||||
|
count: op.count,
|
||||||
|
credits: op.credits,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Ensure all expected types exist
|
||||||
|
const expectedTypes = ['clustering', 'ideas', 'content', 'images'];
|
||||||
|
for (const type of expectedTypes) {
|
||||||
|
if (!mappedOperations.find(op => op.type === type)) {
|
||||||
|
mappedOperations.push({ type, count: 0, credits: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setAIOperations({
|
setAIOperations({
|
||||||
period: '7d',
|
period: stats.ai_operations.period,
|
||||||
operations: [
|
operations: mappedOperations,
|
||||||
{ type: 'clustering', count: clusteringOps, credits: clusteringOps * 10 },
|
totals: stats.ai_operations.totals,
|
||||||
{ type: 'ideas', count: ideasOps, credits: ideasOps * 2 },
|
|
||||||
{ type: 'content', count: contentOps, credits: contentOps * 50 },
|
|
||||||
{ type: 'images', count: imageOps, credits: imageOps * 5 },
|
|
||||||
],
|
|
||||||
totals: {
|
|
||||||
count: clusteringOps + ideasOps + contentOps + imageOps,
|
|
||||||
credits: (clusteringOps * 10) + (ideasOps * 2) + (contentOps * 50) + (imageOps * 5),
|
|
||||||
successRate: 98.5,
|
|
||||||
avgCreditsPerOp: contentOps > 0 ? 18.6 : 0,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set automation status (would come from API in real implementation)
|
// Set automation status (would come from automation API)
|
||||||
setAutomationData({
|
setAutomationData({
|
||||||
status: sites.length > 0 ? 'active' : 'not_configured',
|
status: sites.length > 0 ? 'active' : 'not_configured',
|
||||||
schedule: sites.length > 0 ? 'Daily 9 AM' : undefined,
|
schedule: sites.length > 0 ? 'Daily 9 AM' : undefined,
|
||||||
lastRun: sites.length > 0 ? {
|
lastRun: sites.length > 0 && counts.content.total > 0 ? {
|
||||||
timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000),
|
timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000),
|
||||||
clustered: Math.min(12, totalKeywords),
|
clustered: pipeline.clusters,
|
||||||
ideas: Math.min(8, totalIdeas),
|
ideas: pipeline.ideas,
|
||||||
content: Math.min(5, totalContent),
|
content: counts.content.total,
|
||||||
images: Math.min(15, totalImages),
|
images: counts.images.total,
|
||||||
success: true,
|
success: true,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
nextRun: sites.length > 0 ? new Date(Date.now() + 12 * 60 * 60 * 1000) : undefined,
|
nextRun: sites.length > 0 ? new Date(Date.now() + 12 * 60 * 60 * 1000) : undefined,
|
||||||
@@ -352,7 +286,7 @@ export default function Home() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [siteFilter, sites.length, toast]);
|
}, [siteFilter, sites, toast]);
|
||||||
|
|
||||||
// Fetch dashboard data when filter changes
|
// Fetch dashboard data when filter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchClusters,
|
fetchClusters,
|
||||||
|
fetchImages,
|
||||||
createCluster,
|
createCluster,
|
||||||
updateCluster,
|
updateCluster,
|
||||||
deleteCluster,
|
deleteCluster,
|
||||||
@@ -41,6 +42,7 @@ export default function Clusters() {
|
|||||||
// Total counts for footer widget (not page-filtered)
|
// Total counts for footer widget (not page-filtered)
|
||||||
const [totalWithIdeas, setTotalWithIdeas] = useState(0);
|
const [totalWithIdeas, setTotalWithIdeas] = useState(0);
|
||||||
const [totalReady, setTotalReady] = useState(0);
|
const [totalReady, setTotalReady] = useState(0);
|
||||||
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -97,6 +99,10 @@ export default function Clusters() {
|
|||||||
status: 'new',
|
status: 'new',
|
||||||
});
|
});
|
||||||
setTotalReady(newRes.count || 0);
|
setTotalReady(newRes.count || 0);
|
||||||
|
|
||||||
|
// Get actual total images count
|
||||||
|
const imagesRes = await fetchImages({ page_size: 1 });
|
||||||
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading total metrics:', error);
|
console.error('Error loading total metrics:', error);
|
||||||
}
|
}
|
||||||
@@ -184,18 +190,17 @@ export default function Clusters() {
|
|||||||
};
|
};
|
||||||
}, [loadClusters]);
|
}, [loadClusters]);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search - reset to page 1 when search term changes
|
||||||
|
// Only depend on searchTerm to avoid pagination reset on page navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (currentPage === 1) {
|
// Always reset to page 1 when search changes
|
||||||
loadClusters();
|
// The main useEffect will handle reloading when currentPage changes
|
||||||
} else {
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm, currentPage, loadClusters]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Reset to page 1 when pageSize changes
|
// Reset to page 1 when pageSize changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -380,16 +385,43 @@ export default function Clusters() {
|
|||||||
handleRowAction,
|
handleRowAction,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// Calculate header metrics - use totalWithIdeas/totalReady from API calls (not page data)
|
||||||
|
// This ensures metrics show correct totals across all pages, not just current page
|
||||||
const headerMetrics = useMemo(() => {
|
const headerMetrics = useMemo(() => {
|
||||||
if (!pageConfig?.headerMetrics) return [];
|
if (!pageConfig?.headerMetrics) return [];
|
||||||
return pageConfig.headerMetrics.map((metric) => ({
|
|
||||||
|
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||||
|
return pageConfig.headerMetrics.map((metric) => {
|
||||||
|
let value: number;
|
||||||
|
|
||||||
|
switch (metric.label) {
|
||||||
|
case 'Clusters':
|
||||||
|
value = totalCount || 0;
|
||||||
|
break;
|
||||||
|
case 'New':
|
||||||
|
// Use totalReady from loadTotalMetrics() (clusters without ideas)
|
||||||
|
value = totalReady;
|
||||||
|
break;
|
||||||
|
case 'Keywords':
|
||||||
|
// Sum of keywords across all clusters on current page (this is acceptable for display)
|
||||||
|
value = clusters.reduce((sum: number, c) => sum + (c.keywords_count || 0), 0);
|
||||||
|
break;
|
||||||
|
case 'Volume':
|
||||||
|
// Sum of volume across all clusters on current page (this is acceptable for display)
|
||||||
|
value = clusters.reduce((sum: number, c) => sum + (c.total_volume || 0), 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = metric.calculate({ clusters, totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
label: metric.label,
|
label: metric.label,
|
||||||
value: metric.calculate({ clusters, totalCount }),
|
value,
|
||||||
accentColor: metric.accentColor,
|
accentColor: metric.accentColor,
|
||||||
tooltip: (metric as any).tooltip,
|
tooltip: (metric as any).tooltip,
|
||||||
}));
|
};
|
||||||
}, [pageConfig?.headerMetrics, clusters, totalCount]);
|
});
|
||||||
|
}, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas]);
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -589,7 +621,7 @@ export default function Clusters() {
|
|||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Published', value: 0, color: 'green' },
|
{ label: 'Published', value: 0, color: 'green' },
|
||||||
],
|
],
|
||||||
analyticsHref: '/account/usage',
|
analyticsHref: '/account/usage',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchContentIdeas,
|
fetchContentIdeas,
|
||||||
|
fetchImages,
|
||||||
createContentIdea,
|
createContentIdea,
|
||||||
updateContentIdea,
|
updateContentIdea,
|
||||||
deleteContentIdea,
|
deleteContentIdea,
|
||||||
@@ -44,6 +45,7 @@ export default function Ideas() {
|
|||||||
// Total counts for footer widget (not page-filtered)
|
// Total counts for footer widget (not page-filtered)
|
||||||
const [totalInTasks, setTotalInTasks] = useState(0);
|
const [totalInTasks, setTotalInTasks] = useState(0);
|
||||||
const [totalPending, setTotalPending] = useState(0);
|
const [totalPending, setTotalPending] = useState(0);
|
||||||
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -117,6 +119,10 @@ export default function Ideas() {
|
|||||||
status: 'new',
|
status: 'new',
|
||||||
});
|
});
|
||||||
setTotalPending(newRes.count || 0);
|
setTotalPending(newRes.count || 0);
|
||||||
|
|
||||||
|
// Get actual total images count
|
||||||
|
const imagesRes = await fetchImages({ page_size: 1 });
|
||||||
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading total metrics:', error);
|
console.error('Error loading total metrics:', error);
|
||||||
}
|
}
|
||||||
@@ -189,18 +195,17 @@ export default function Ideas() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search - reset to page 1 when search term changes
|
||||||
|
// Only depend on searchTerm to avoid pagination reset on page navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (currentPage === 1) {
|
// Always reset to page 1 when search changes
|
||||||
loadIdeas();
|
// The main useEffect will handle reloading when currentPage changes
|
||||||
} else {
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm, currentPage, loadIdeas]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Handle sorting
|
// Handle sorting
|
||||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||||
@@ -289,16 +294,43 @@ export default function Ideas() {
|
|||||||
});
|
});
|
||||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
|
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// Calculate header metrics - use totalInTasks/totalPending from API calls (not page data)
|
||||||
|
// This ensures metrics show correct totals across all pages, not just current page
|
||||||
const headerMetrics = useMemo(() => {
|
const headerMetrics = useMemo(() => {
|
||||||
if (!pageConfig?.headerMetrics) return [];
|
if (!pageConfig?.headerMetrics) return [];
|
||||||
return pageConfig.headerMetrics.map((metric) => ({
|
|
||||||
|
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||||
|
return pageConfig.headerMetrics.map((metric) => {
|
||||||
|
let value: number;
|
||||||
|
|
||||||
|
switch (metric.label) {
|
||||||
|
case 'Ideas':
|
||||||
|
value = totalCount || 0;
|
||||||
|
break;
|
||||||
|
case 'New':
|
||||||
|
// Use totalPending from loadTotalMetrics() (ideas with status='new')
|
||||||
|
value = totalPending;
|
||||||
|
break;
|
||||||
|
case 'Queued':
|
||||||
|
// Use totalInTasks from loadTotalMetrics() (ideas with status='queued')
|
||||||
|
value = totalInTasks;
|
||||||
|
break;
|
||||||
|
case 'Completed':
|
||||||
|
// Calculate completed from totalCount - (totalPending + totalInTasks)
|
||||||
|
value = Math.max(0, totalCount - totalPending - totalInTasks);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = metric.calculate({ ideas, totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
label: metric.label,
|
label: metric.label,
|
||||||
value: metric.calculate({ ideas, totalCount }),
|
value,
|
||||||
accentColor: metric.accentColor,
|
accentColor: metric.accentColor,
|
||||||
tooltip: (metric as any).tooltip,
|
tooltip: (metric as any).tooltip,
|
||||||
}));
|
};
|
||||||
}, [pageConfig?.headerMetrics, ideas, totalCount]);
|
});
|
||||||
|
}, [pageConfig?.headerMetrics, ideas, totalCount, totalPending, totalInTasks]);
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -522,7 +554,7 @@ export default function Ideas() {
|
|||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
|
{ label: 'Content Generated', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
|
||||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Published', value: 0, color: 'green' },
|
{ label: 'Published', value: 0, color: 'green' },
|
||||||
],
|
],
|
||||||
analyticsHref: '/account/usage',
|
analyticsHref: '/account/usage',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchKeywords,
|
fetchKeywords,
|
||||||
|
fetchImages,
|
||||||
createKeyword,
|
createKeyword,
|
||||||
updateKeyword,
|
updateKeyword,
|
||||||
deleteKeyword,
|
deleteKeyword,
|
||||||
@@ -50,6 +51,7 @@ export default function Keywords() {
|
|||||||
const [totalClustered, setTotalClustered] = useState(0);
|
const [totalClustered, setTotalClustered] = useState(0);
|
||||||
const [totalUnmapped, setTotalUnmapped] = useState(0);
|
const [totalUnmapped, setTotalUnmapped] = useState(0);
|
||||||
const [totalVolume, setTotalVolume] = useState(0);
|
const [totalVolume, setTotalVolume] = useState(0);
|
||||||
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
// Filter state - match Keywords.tsx
|
// Filter state - match Keywords.tsx
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -141,6 +143,10 @@ export default function Keywords() {
|
|||||||
// For now, we'll just calculate from current data or set to 0
|
// For now, we'll just calculate from current data or set to 0
|
||||||
// TODO: Backend should provide total volume as an aggregated metric
|
// TODO: Backend should provide total volume as an aggregated metric
|
||||||
setTotalVolume(0);
|
setTotalVolume(0);
|
||||||
|
|
||||||
|
// Get actual total images count
|
||||||
|
const imagesRes = await fetchImages({ page_size: 1 });
|
||||||
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading total metrics:', error);
|
console.error('Error loading total metrics:', error);
|
||||||
}
|
}
|
||||||
@@ -524,16 +530,43 @@ export default function Keywords() {
|
|||||||
activeSite,
|
activeSite,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate header metrics from config (matching reference plugin KPIs from kpi-config.php)
|
// Calculate header metrics - use totalClustered/totalUnmapped from API calls (not page data)
|
||||||
|
// This ensures metrics show correct totals across all pages, not just current page
|
||||||
const headerMetrics = useMemo(() => {
|
const headerMetrics = useMemo(() => {
|
||||||
if (!pageConfig?.headerMetrics) return [];
|
if (!pageConfig?.headerMetrics) return [];
|
||||||
return pageConfig.headerMetrics.map((metric) => ({
|
|
||||||
|
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||||
|
return pageConfig.headerMetrics.map((metric) => {
|
||||||
|
let value: number;
|
||||||
|
|
||||||
|
switch (metric.label) {
|
||||||
|
case 'Keywords':
|
||||||
|
value = totalCount || 0;
|
||||||
|
break;
|
||||||
|
case 'Clustered':
|
||||||
|
// Use totalClustered from loadTotalMetrics() instead of filtering page data
|
||||||
|
value = totalClustered;
|
||||||
|
break;
|
||||||
|
case 'Unmapped':
|
||||||
|
// Use totalUnmapped from loadTotalMetrics() instead of filtering page data
|
||||||
|
value = totalUnmapped;
|
||||||
|
break;
|
||||||
|
case 'Volume':
|
||||||
|
// Use totalVolume from loadTotalMetrics() (if implemented) or keep original
|
||||||
|
value = totalVolume || keywords.reduce((sum: number, k) => sum + (k.volume || 0), 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = metric.calculate({ keywords, totalCount, clusters });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
label: metric.label,
|
label: metric.label,
|
||||||
value: metric.calculate({ keywords, totalCount, clusters }),
|
value,
|
||||||
accentColor: metric.accentColor,
|
accentColor: metric.accentColor,
|
||||||
tooltip: (metric as any).tooltip, // Add tooltip support
|
tooltip: (metric as any).tooltip,
|
||||||
}));
|
};
|
||||||
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters]);
|
});
|
||||||
|
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters, totalClustered, totalUnmapped, totalVolume]);
|
||||||
|
|
||||||
// Calculate workflow insights based on UX doc principles
|
// Calculate workflow insights based on UX doc principles
|
||||||
const workflowStats = useMemo(() => {
|
const workflowStats = useMemo(() => {
|
||||||
@@ -819,7 +852,7 @@ export default function Keywords() {
|
|||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Published', value: 0, color: 'green' },
|
{ label: 'Published', value: 0, color: 'green' },
|
||||||
],
|
],
|
||||||
creditsUsed: 0,
|
creditsUsed: 0,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchContent,
|
fetchContent,
|
||||||
|
fetchImages,
|
||||||
Content,
|
Content,
|
||||||
ContentListResponse,
|
ContentListResponse,
|
||||||
ContentFilters,
|
ContentFilters,
|
||||||
@@ -35,6 +36,11 @@ export default function Approved() {
|
|||||||
const [content, setContent] = useState<Content[]>([]);
|
const [content, setContent] = useState<Content[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Total counts for footer widget and header metrics (not page-filtered)
|
||||||
|
const [totalOnSite, setTotalOnSite] = useState(0);
|
||||||
|
const [totalPendingPublish, setTotalPendingPublish] = useState(0);
|
||||||
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
// Filter state - default to approved status
|
// Filter state - default to approved status
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [publishStatusFilter, setPublishStatusFilter] = useState('');
|
const [publishStatusFilter, setPublishStatusFilter] = useState('');
|
||||||
@@ -50,6 +56,36 @@ export default function Approved() {
|
|||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
const [showContent, setShowContent] = useState(false);
|
const [showContent, setShowContent] = useState(false);
|
||||||
|
|
||||||
|
// Load total metrics for footer widget and header metrics (not affected by pagination)
|
||||||
|
const loadTotalMetrics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch all approved content to calculate totals
|
||||||
|
const data = await fetchContent({
|
||||||
|
status: 'published', // Backend uses 'published' for approved content
|
||||||
|
page_size: 1000, // Fetch enough to count
|
||||||
|
});
|
||||||
|
|
||||||
|
const allContent = data.results || [];
|
||||||
|
// Count by external_id presence
|
||||||
|
const onSite = allContent.filter(c => c.external_id).length;
|
||||||
|
const pending = allContent.filter(c => !c.external_id).length;
|
||||||
|
|
||||||
|
setTotalOnSite(onSite);
|
||||||
|
setTotalPendingPublish(pending);
|
||||||
|
|
||||||
|
// Get actual total images count
|
||||||
|
const imagesRes = await fetchImages({ page_size: 1 });
|
||||||
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading total metrics:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load total metrics on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadTotalMetrics();
|
||||||
|
}, [loadTotalMetrics]);
|
||||||
|
|
||||||
// Load content - filtered for approved status (API still uses 'published' internally)
|
// Load content - filtered for approved status (API still uses 'published' internally)
|
||||||
const loadContent = useCallback(async () => {
|
const loadContent = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -137,18 +173,17 @@ export default function Approved() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search - reset to page 1 when search term changes
|
||||||
|
// Only depend on searchTerm to avoid pagination reset on page navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (currentPage === 1) {
|
// Always reset to page 1 when search changes
|
||||||
loadContent();
|
// The main useEffect will handle reloading when currentPage changes
|
||||||
} else {
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm, currentPage, loadContent]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Handle sorting
|
// Handle sorting
|
||||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||||
@@ -292,15 +327,38 @@ export default function Approved() {
|
|||||||
});
|
});
|
||||||
}, [searchTerm, publishStatusFilter, activeSector, navigate]);
|
}, [searchTerm, publishStatusFilter, activeSector, navigate]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// Calculate header metrics - use totals from API calls (not page data)
|
||||||
|
// This ensures metrics show correct totals across all pages, not just current page
|
||||||
const headerMetrics = useMemo(() => {
|
const headerMetrics = useMemo(() => {
|
||||||
if (!pageConfig?.headerMetrics) return [];
|
if (!pageConfig?.headerMetrics) return [];
|
||||||
return pageConfig.headerMetrics.map((metric) => ({
|
|
||||||
|
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||||
|
return pageConfig.headerMetrics.map((metric) => {
|
||||||
|
let value: number;
|
||||||
|
|
||||||
|
switch (metric.label) {
|
||||||
|
case 'Approved':
|
||||||
|
value = totalCount || 0;
|
||||||
|
break;
|
||||||
|
case 'On Site':
|
||||||
|
// Use totalOnSite from loadTotalMetrics()
|
||||||
|
value = totalOnSite;
|
||||||
|
break;
|
||||||
|
case 'Pending':
|
||||||
|
// Use totalPendingPublish from loadTotalMetrics()
|
||||||
|
value = totalPendingPublish;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = metric.calculate({ content, totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
label: metric.label,
|
label: metric.label,
|
||||||
value: metric.calculate({ content, totalCount }),
|
value,
|
||||||
accentColor: metric.accentColor,
|
accentColor: metric.accentColor,
|
||||||
}));
|
};
|
||||||
}, [pageConfig?.headerMetrics, content, totalCount]);
|
});
|
||||||
|
}, [pageConfig?.headerMetrics, content, totalCount, totalOnSite, totalPendingPublish]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -398,7 +456,7 @@ export default function Approved() {
|
|||||||
fromHref: '/writer/content',
|
fromHref: '/writer/content',
|
||||||
actionLabel: 'Generate Images',
|
actionLabel: 'Generate Images',
|
||||||
toLabel: 'Images',
|
toLabel: 'Images',
|
||||||
toValue: 0,
|
toValue: totalImagesCount,
|
||||||
toHref: '/writer/images',
|
toHref: '/writer/images',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
@@ -431,7 +489,7 @@ export default function Approved() {
|
|||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Articles Published', value: totalCount, color: 'green' },
|
{ label: 'Articles Published', value: totalCount, color: 'green' },
|
||||||
],
|
],
|
||||||
analyticsHref: '/account/usage',
|
analyticsHref: '/account/usage',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchContent,
|
fetchContent,
|
||||||
|
fetchImages,
|
||||||
Content as ContentType,
|
Content as ContentType,
|
||||||
ContentFilters,
|
ContentFilters,
|
||||||
generateImagePrompts,
|
generateImagePrompts,
|
||||||
@@ -35,6 +36,12 @@ export default function Content() {
|
|||||||
const [content, setContent] = useState<ContentType[]>([]);
|
const [content, setContent] = useState<ContentType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Total counts for footer widget and header metrics (not page-filtered)
|
||||||
|
const [totalDraft, setTotalDraft] = useState(0);
|
||||||
|
const [totalReview, setTotalReview] = useState(0);
|
||||||
|
const [totalPublished, setTotalPublished] = useState(0);
|
||||||
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('draft');
|
const [statusFilter, setStatusFilter] = useState('draft');
|
||||||
@@ -55,6 +62,46 @@ export default function Content() {
|
|||||||
const progressModal = useProgressModal();
|
const progressModal = useProgressModal();
|
||||||
const hasReloadedRef = useRef(false);
|
const hasReloadedRef = useRef(false);
|
||||||
|
|
||||||
|
// Load total metrics for footer widget and header metrics (not affected by pagination)
|
||||||
|
const loadTotalMetrics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Get content with status='draft'
|
||||||
|
const draftRes = await fetchContent({
|
||||||
|
page_size: 1,
|
||||||
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'draft',
|
||||||
|
});
|
||||||
|
setTotalDraft(draftRes.count || 0);
|
||||||
|
|
||||||
|
// Get content with status='review'
|
||||||
|
const reviewRes = await fetchContent({
|
||||||
|
page_size: 1,
|
||||||
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'review',
|
||||||
|
});
|
||||||
|
setTotalReview(reviewRes.count || 0);
|
||||||
|
|
||||||
|
// Get content with status='published'
|
||||||
|
const publishedRes = await fetchContent({
|
||||||
|
page_size: 1,
|
||||||
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'published',
|
||||||
|
});
|
||||||
|
setTotalPublished(publishedRes.count || 0);
|
||||||
|
|
||||||
|
// Get actual total images count
|
||||||
|
const imagesRes = await fetchImages({ page_size: 1 });
|
||||||
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading total metrics:', error);
|
||||||
|
}
|
||||||
|
}, [activeSector]);
|
||||||
|
|
||||||
|
// Load total metrics when sector changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadTotalMetrics();
|
||||||
|
}, [loadTotalMetrics]);
|
||||||
|
|
||||||
// Load content - wrapped in useCallback
|
// Load content - wrapped in useCallback
|
||||||
const loadContent = useCallback(async () => {
|
const loadContent = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -115,18 +162,17 @@ export default function Content() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search - reset to page 1 when search term changes
|
||||||
|
// Only depend on searchTerm to avoid pagination reset on page navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (currentPage === 1) {
|
// Always reset to page 1 when search changes
|
||||||
loadContent();
|
// The main useEffect will handle reloading when currentPage changes
|
||||||
} else {
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm, currentPage, loadContent]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Handle sorting
|
// Handle sorting
|
||||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||||
@@ -160,16 +206,43 @@ export default function Content() {
|
|||||||
handleRowClick,
|
handleRowClick,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// Calculate header metrics - use totals from API calls (not page data)
|
||||||
|
// This ensures metrics show correct totals across all pages, not just current page
|
||||||
const headerMetrics = useMemo(() => {
|
const headerMetrics = useMemo(() => {
|
||||||
if (!pageConfig?.headerMetrics) return [];
|
if (!pageConfig?.headerMetrics) return [];
|
||||||
return pageConfig.headerMetrics.map((metric) => ({
|
|
||||||
|
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||||
|
return pageConfig.headerMetrics.map((metric) => {
|
||||||
|
let value: number;
|
||||||
|
|
||||||
|
switch (metric.label) {
|
||||||
|
case 'Content':
|
||||||
|
value = totalCount || 0;
|
||||||
|
break;
|
||||||
|
case 'Draft':
|
||||||
|
// Use totalDraft from loadTotalMetrics()
|
||||||
|
value = totalDraft;
|
||||||
|
break;
|
||||||
|
case 'In Review':
|
||||||
|
// Use totalReview from loadTotalMetrics()
|
||||||
|
value = totalReview;
|
||||||
|
break;
|
||||||
|
case 'Published':
|
||||||
|
// Use totalPublished from loadTotalMetrics()
|
||||||
|
value = totalPublished;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = metric.calculate({ content, totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
label: metric.label,
|
label: metric.label,
|
||||||
value: metric.calculate({ content, totalCount }),
|
value,
|
||||||
accentColor: metric.accentColor,
|
accentColor: metric.accentColor,
|
||||||
tooltip: (metric as any).tooltip,
|
tooltip: (metric as any).tooltip,
|
||||||
}));
|
};
|
||||||
}, [pageConfig?.headerMetrics, content, totalCount]);
|
});
|
||||||
|
}, [pageConfig?.headerMetrics, content, totalCount, totalDraft, totalReview, totalPublished]);
|
||||||
|
|
||||||
const handleRowAction = useCallback(async (action: string, row: ContentType) => {
|
const handleRowAction = useCallback(async (action: string, row: ContentType) => {
|
||||||
if (action === 'view_on_wordpress') {
|
if (action === 'view_on_wordpress') {
|
||||||
@@ -347,7 +420,7 @@ export default function Content() {
|
|||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: totalCount, color: 'blue' },
|
{ label: 'Content Generated', value: totalCount, color: 'blue' },
|
||||||
{ label: 'Images Created', value: content.filter(c => c.has_generated_images).length, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
|
{ label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
|
||||||
],
|
],
|
||||||
analyticsHref: '/account/usage',
|
analyticsHref: '/account/usage',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchContentImages,
|
fetchContentImages,
|
||||||
|
fetchImages,
|
||||||
ContentImagesGroup,
|
ContentImagesGroup,
|
||||||
ContentImagesResponse,
|
ContentImagesResponse,
|
||||||
fetchImageGenerationSettings,
|
fetchImageGenerationSettings,
|
||||||
@@ -35,6 +36,12 @@ export default function Images() {
|
|||||||
const [images, setImages] = useState<ContentImagesGroup[]>([]);
|
const [images, setImages] = useState<ContentImagesGroup[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Total counts for footer widget and header metrics (not page-filtered)
|
||||||
|
const [totalComplete, setTotalComplete] = useState(0);
|
||||||
|
const [totalPartial, setTotalPartial] = useState(0);
|
||||||
|
const [totalPending, setTotalPending] = useState(0);
|
||||||
|
const [totalImagesCount, setTotalImagesCount] = useState(0); // Actual images count
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
@@ -69,6 +76,49 @@ export default function Images() {
|
|||||||
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
|
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
|
||||||
const [modalImageUrl, setModalImageUrl] = useState<string | null>(null);
|
const [modalImageUrl, setModalImageUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load total metrics for footer widget and header metrics (not affected by pagination)
|
||||||
|
const loadTotalMetrics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch content-grouped images for status counts
|
||||||
|
const data: ContentImagesResponse = await fetchContentImages({});
|
||||||
|
const allImages = data.results || [];
|
||||||
|
|
||||||
|
// Count by overall_status (content-level status)
|
||||||
|
let complete = 0;
|
||||||
|
let partial = 0;
|
||||||
|
let pending = 0;
|
||||||
|
|
||||||
|
allImages.forEach(img => {
|
||||||
|
switch (img.overall_status) {
|
||||||
|
case 'complete':
|
||||||
|
complete++;
|
||||||
|
break;
|
||||||
|
case 'partial':
|
||||||
|
partial++;
|
||||||
|
break;
|
||||||
|
case 'pending':
|
||||||
|
pending++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTotalComplete(complete);
|
||||||
|
setTotalPartial(partial);
|
||||||
|
setTotalPending(pending);
|
||||||
|
|
||||||
|
// Fetch ACTUAL total images count from the images endpoint
|
||||||
|
const imagesData = await fetchImages({ page_size: 1 });
|
||||||
|
setTotalImagesCount(imagesData.count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading total metrics:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load total metrics on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadTotalMetrics();
|
||||||
|
}, [loadTotalMetrics]);
|
||||||
|
|
||||||
// Load images - wrapped in useCallback
|
// Load images - wrapped in useCallback
|
||||||
const loadImages = useCallback(async () => {
|
const loadImages = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -155,18 +205,17 @@ export default function Images() {
|
|||||||
};
|
};
|
||||||
}, [loadImages]);
|
}, [loadImages]);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search - reset to page 1 when search term changes
|
||||||
|
// Only depend on searchTerm to avoid pagination reset on page navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (currentPage === 1) {
|
// Always reset to page 1 when search changes
|
||||||
loadImages();
|
// The main useEffect will handle reloading when currentPage changes
|
||||||
} else {
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm, currentPage, loadImages]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Handle sorting
|
// Handle sorting
|
||||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||||
@@ -438,16 +487,54 @@ export default function Images() {
|
|||||||
});
|
});
|
||||||
}, [searchTerm, statusFilter, maxInArticleImages, handleGenerateImages, handleImageClick]);
|
}, [searchTerm, statusFilter, maxInArticleImages, handleGenerateImages, handleImageClick]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// Calculate header metrics - use totals from API calls (not page data)
|
||||||
|
// This ensures metrics show correct totals across all pages, not just current page
|
||||||
const headerMetrics = useMemo(() => {
|
const headerMetrics = useMemo(() => {
|
||||||
if (!pageConfig?.headerMetrics) return [];
|
if (!pageConfig?.headerMetrics) return [];
|
||||||
return pageConfig.headerMetrics.map((metric) => ({
|
|
||||||
|
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||||
|
// Also add a "Total Images" metric at the end
|
||||||
|
const baseMetrics = pageConfig.headerMetrics.map((metric) => {
|
||||||
|
let value: number;
|
||||||
|
|
||||||
|
switch (metric.label) {
|
||||||
|
case 'Content':
|
||||||
|
value = totalCount || 0;
|
||||||
|
break;
|
||||||
|
case 'Complete':
|
||||||
|
// Use totalComplete from loadTotalMetrics()
|
||||||
|
value = totalComplete;
|
||||||
|
break;
|
||||||
|
case 'Partial':
|
||||||
|
// Use totalPartial from loadTotalMetrics()
|
||||||
|
value = totalPartial;
|
||||||
|
break;
|
||||||
|
case 'Pending':
|
||||||
|
// Use totalPending from loadTotalMetrics()
|
||||||
|
value = totalPending;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = metric.calculate({ images, totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
label: metric.label,
|
label: metric.label,
|
||||||
value: metric.calculate({ images, totalCount }),
|
value,
|
||||||
accentColor: metric.accentColor,
|
accentColor: metric.accentColor,
|
||||||
tooltip: (metric as any).tooltip,
|
tooltip: (metric as any).tooltip,
|
||||||
}));
|
};
|
||||||
}, [pageConfig?.headerMetrics, images, totalCount]);
|
});
|
||||||
|
|
||||||
|
// Add total images count metric
|
||||||
|
baseMetrics.push({
|
||||||
|
label: 'Total Images',
|
||||||
|
value: totalImagesCount,
|
||||||
|
accentColor: 'purple' as const,
|
||||||
|
tooltip: 'Total number of images across all content',
|
||||||
|
});
|
||||||
|
|
||||||
|
return baseMetrics;
|
||||||
|
}, [pageConfig?.headerMetrics, images, totalCount, totalComplete, totalPartial, totalPending, totalImagesCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -617,7 +704,7 @@ export default function Images() {
|
|||||||
fromHref: '/writer/content',
|
fromHref: '/writer/content',
|
||||||
actionLabel: 'Generate Images',
|
actionLabel: 'Generate Images',
|
||||||
toLabel: 'Images',
|
toLabel: 'Images',
|
||||||
toValue: totalCount,
|
toValue: totalImagesCount,
|
||||||
toHref: '/writer/images',
|
toHref: '/writer/images',
|
||||||
progress: 100,
|
progress: 100,
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
@@ -650,7 +737,7 @@ export default function Images() {
|
|||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||||
{ label: 'Images Created', value: totalCount, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Articles Published', value: 0, color: 'green' },
|
{ label: 'Articles Published', value: 0, color: 'green' },
|
||||||
],
|
],
|
||||||
analyticsHref: '/account/usage',
|
analyticsHref: '/account/usage',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchContent,
|
fetchContent,
|
||||||
|
fetchImages,
|
||||||
Content,
|
Content,
|
||||||
ContentListResponse,
|
ContentListResponse,
|
||||||
ContentFilters,
|
ContentFilters,
|
||||||
@@ -31,6 +32,7 @@ export default function Review() {
|
|||||||
// Data state
|
// Data state
|
||||||
const [content, setContent] = useState<Content[]>([]);
|
const [content, setContent] = useState<Content[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
// Filter state - default to review status
|
// Filter state - default to review status
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -83,6 +85,19 @@ export default function Review() {
|
|||||||
loadContent();
|
loadContent();
|
||||||
}, [loadContent]);
|
}, [loadContent]);
|
||||||
|
|
||||||
|
// Load total images count
|
||||||
|
useEffect(() => {
|
||||||
|
const loadImageCount = async () => {
|
||||||
|
try {
|
||||||
|
const imagesRes = await fetchImages({ page_size: 1 });
|
||||||
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading image count:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadImageCount();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Listen for site and sector changes and refresh data
|
// Listen for site and sector changes and refresh data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSiteChange = () => {
|
const handleSiteChange = () => {
|
||||||
@@ -494,7 +509,7 @@ export default function Review() {
|
|||||||
fromHref: '/writer/content',
|
fromHref: '/writer/content',
|
||||||
actionLabel: 'Generate Images',
|
actionLabel: 'Generate Images',
|
||||||
toLabel: 'Images',
|
toLabel: 'Images',
|
||||||
toValue: 0,
|
toValue: totalImagesCount,
|
||||||
toHref: '/writer/images',
|
toHref: '/writer/images',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
@@ -527,7 +542,7 @@ export default function Review() {
|
|||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Articles Published', value: 0, color: 'green' },
|
{ label: 'Articles Published', value: 0, color: 'green' },
|
||||||
],
|
],
|
||||||
analyticsHref: '/account/usage',
|
analyticsHref: '/account/usage',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
|
fetchImages,
|
||||||
createTask,
|
createTask,
|
||||||
updateTask,
|
updateTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
@@ -43,6 +44,13 @@ export default function Tasks() {
|
|||||||
const [clusters, setClusters] = useState<Cluster[]>([]);
|
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Total counts for footer widget and header metrics (not page-filtered)
|
||||||
|
const [totalQueued, setTotalQueued] = useState(0);
|
||||||
|
const [totalProcessing, setTotalProcessing] = useState(0);
|
||||||
|
const [totalCompleted, setTotalCompleted] = useState(0);
|
||||||
|
const [totalFailed, setTotalFailed] = useState(0);
|
||||||
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
@@ -97,6 +105,54 @@ export default function Tasks() {
|
|||||||
loadClusters();
|
loadClusters();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load total metrics for footer widget and header metrics (not affected by pagination)
|
||||||
|
const loadTotalMetrics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Get tasks with status='queued'
|
||||||
|
const queuedRes = await fetchTasks({
|
||||||
|
page_size: 1,
|
||||||
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'queued',
|
||||||
|
});
|
||||||
|
setTotalQueued(queuedRes.count || 0);
|
||||||
|
|
||||||
|
// Get tasks with status='in_progress'
|
||||||
|
const processingRes = await fetchTasks({
|
||||||
|
page_size: 1,
|
||||||
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'in_progress',
|
||||||
|
});
|
||||||
|
setTotalProcessing(processingRes.count || 0);
|
||||||
|
|
||||||
|
// Get tasks with status='completed'
|
||||||
|
const completedRes = await fetchTasks({
|
||||||
|
page_size: 1,
|
||||||
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
setTotalCompleted(completedRes.count || 0);
|
||||||
|
|
||||||
|
// Get tasks with status='failed'
|
||||||
|
const failedRes = await fetchTasks({
|
||||||
|
page_size: 1,
|
||||||
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
setTotalFailed(failedRes.count || 0);
|
||||||
|
|
||||||
|
// Get actual total images count
|
||||||
|
const imagesRes = await fetchImages({ page_size: 1 });
|
||||||
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading total metrics:', error);
|
||||||
|
}
|
||||||
|
}, [activeSector]);
|
||||||
|
|
||||||
|
// Load total metrics when sector changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadTotalMetrics();
|
||||||
|
}, [loadTotalMetrics]);
|
||||||
|
|
||||||
// Load tasks - wrapped in useCallback
|
// Load tasks - wrapped in useCallback
|
||||||
const loadTasks = useCallback(async () => {
|
const loadTasks = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -167,18 +223,17 @@ export default function Tasks() {
|
|||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search - reset to page 1 when search term changes
|
||||||
|
// Only depend on searchTerm to avoid pagination reset on page navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (currentPage === 1) {
|
// Always reset to page 1 when search changes
|
||||||
loadTasks();
|
// The main useEffect will handle reloading when currentPage changes
|
||||||
} else {
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm, currentPage, loadTasks]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Handle sorting
|
// Handle sorting
|
||||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||||
@@ -318,16 +373,47 @@ export default function Tasks() {
|
|||||||
});
|
});
|
||||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, sourceFilter]);
|
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, sourceFilter]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// Calculate header metrics - use totals from API calls (not page data)
|
||||||
|
// This ensures metrics show correct totals across all pages, not just current page
|
||||||
const headerMetrics = useMemo(() => {
|
const headerMetrics = useMemo(() => {
|
||||||
if (!pageConfig?.headerMetrics) return [];
|
if (!pageConfig?.headerMetrics) return [];
|
||||||
return pageConfig.headerMetrics.map((metric) => ({
|
|
||||||
|
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||||
|
return pageConfig.headerMetrics.map((metric) => {
|
||||||
|
let value: number;
|
||||||
|
|
||||||
|
switch (metric.label) {
|
||||||
|
case 'Tasks':
|
||||||
|
value = totalCount || 0;
|
||||||
|
break;
|
||||||
|
case 'In Queue':
|
||||||
|
// Use totalQueued from loadTotalMetrics()
|
||||||
|
value = totalQueued;
|
||||||
|
break;
|
||||||
|
case 'Processing':
|
||||||
|
// Use totalProcessing from loadTotalMetrics()
|
||||||
|
value = totalProcessing;
|
||||||
|
break;
|
||||||
|
case 'Completed':
|
||||||
|
// Use totalCompleted from loadTotalMetrics()
|
||||||
|
value = totalCompleted;
|
||||||
|
break;
|
||||||
|
case 'Failed':
|
||||||
|
// Use totalFailed from loadTotalMetrics()
|
||||||
|
value = totalFailed;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
value = metric.calculate({ tasks, totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
label: metric.label,
|
label: metric.label,
|
||||||
value: metric.calculate({ tasks, totalCount }),
|
value,
|
||||||
accentColor: metric.accentColor,
|
accentColor: metric.accentColor,
|
||||||
tooltip: (metric as any).tooltip,
|
tooltip: (metric as any).tooltip,
|
||||||
}));
|
};
|
||||||
}, [pageConfig?.headerMetrics, tasks, totalCount]);
|
});
|
||||||
|
}, [pageConfig?.headerMetrics, tasks, totalCount, totalQueued, totalProcessing, totalCompleted, totalFailed]);
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -507,7 +593,7 @@ export default function Tasks() {
|
|||||||
fromHref: '/writer/content',
|
fromHref: '/writer/content',
|
||||||
actionLabel: 'Generate Images',
|
actionLabel: 'Generate Images',
|
||||||
toLabel: 'Images',
|
toLabel: 'Images',
|
||||||
toValue: 0,
|
toValue: totalImagesCount,
|
||||||
toHref: '/writer/images',
|
toHref: '/writer/images',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
@@ -540,7 +626,7 @@ export default function Tasks() {
|
|||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: tasks.filter(t => t.status === 'completed').length, color: 'blue' },
|
{ label: 'Content Generated', value: tasks.filter(t => t.status === 'completed').length, color: 'blue' },
|
||||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Published', value: 0, color: 'green' },
|
{ label: 'Published', value: 0, color: 'green' },
|
||||||
],
|
],
|
||||||
analyticsHref: '/account/usage',
|
analyticsHref: '/account/usage',
|
||||||
|
|||||||
@@ -1042,3 +1042,90 @@ export async function getPublicPlans(): Promise<Plan[]> {
|
|||||||
export async function getUsageSummary(): Promise<UsageSummary> {
|
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||||
return fetchAPI('/v1/billing/usage-summary/');
|
return fetchAPI('/v1/billing/usage-summary/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DASHBOARD STATS (Real data for home page)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DashboardAIOperation {
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
credits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardAIOperations {
|
||||||
|
period: string;
|
||||||
|
operations: DashboardAIOperation[];
|
||||||
|
totals: {
|
||||||
|
count: number;
|
||||||
|
credits: number;
|
||||||
|
successRate: number;
|
||||||
|
avgCreditsPerOp: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardActivityItem {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardContentVelocity {
|
||||||
|
thisWeek: {
|
||||||
|
articles: number;
|
||||||
|
words: number;
|
||||||
|
images: number;
|
||||||
|
};
|
||||||
|
thisMonth: {
|
||||||
|
articles: number;
|
||||||
|
words: number;
|
||||||
|
images: number;
|
||||||
|
};
|
||||||
|
total: {
|
||||||
|
articles: number;
|
||||||
|
words: number;
|
||||||
|
images: number;
|
||||||
|
};
|
||||||
|
trend: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPipeline {
|
||||||
|
sites: number;
|
||||||
|
keywords: number;
|
||||||
|
clusters: number;
|
||||||
|
ideas: number;
|
||||||
|
tasks: number;
|
||||||
|
drafts: number;
|
||||||
|
published: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
ai_operations: DashboardAIOperations;
|
||||||
|
recent_activity: DashboardActivityItem[];
|
||||||
|
content_velocity: DashboardContentVelocity;
|
||||||
|
pipeline: DashboardPipeline;
|
||||||
|
counts: {
|
||||||
|
content: {
|
||||||
|
total: number;
|
||||||
|
draft: number;
|
||||||
|
review: number;
|
||||||
|
published: number;
|
||||||
|
};
|
||||||
|
images: {
|
||||||
|
total: number;
|
||||||
|
generated: number;
|
||||||
|
pending: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardStats(params?: { site_id?: number; days?: number }): Promise<DashboardStats> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.site_id) searchParams.append('site_id', params.site_id.toString());
|
||||||
|
if (params?.days) searchParams.append('days', params.days.toString());
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return fetchAPI(`/v1/account/dashboard/stats/${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user