metricsa dn backedn fixes
This commit is contained in:
@@ -170,6 +170,7 @@ class Igny8AdminSite(UnfoldAdminSite):
|
||||
'models': [
|
||||
('igny8_core_auth', 'Plan'),
|
||||
('igny8_core_auth', 'Subscription'),
|
||||
('billing', 'BillingConfiguration'),
|
||||
('billing', 'Invoice'),
|
||||
('billing', 'Payment'),
|
||||
('billing', 'CreditPackage'),
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchContentImages,
|
||||
fetchImages,
|
||||
} from '../../services/api';
|
||||
import ActivityLog from '../../components/Automation/ActivityLog';
|
||||
import ConfigModal from '../../components/Automation/ConfigModal';
|
||||
@@ -130,8 +130,8 @@ const AutomationPage: React.FC = () => {
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||||
fetchContentImages({ page_size: 1, site_id: siteId }),
|
||||
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
||||
fetchImages({ page_size: 1 }),
|
||||
fetchImages({ page_size: 1, status: 'pending' }),
|
||||
]);
|
||||
|
||||
setMetrics({
|
||||
@@ -249,8 +249,8 @@ const AutomationPage: React.FC = () => {
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||||
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||||
fetchContentImages({ page_size: 1, site_id: siteId }),
|
||||
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
||||
fetchImages({ page_size: 1 }),
|
||||
fetchImages({ page_size: 1, status: 'pending' }),
|
||||
]);
|
||||
|
||||
setMetrics({
|
||||
|
||||
@@ -11,15 +11,10 @@ import { useOnboardingStore } from "../../store/onboardingStore";
|
||||
import { useBillingStore } from "../../store/billingStore";
|
||||
import { GridIcon, PlusIcon } from "../../icons";
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchClusters,
|
||||
fetchContentIdeas,
|
||||
fetchTasks,
|
||||
fetchContent,
|
||||
fetchContentImages,
|
||||
fetchSites,
|
||||
Site,
|
||||
} from "../../services/api";
|
||||
import { getDashboardStats } from "../../services/billing.api";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
import { useToast } from "../../components/ui/toast/ToastContainer";
|
||||
@@ -155,49 +150,33 @@ export default function Home() {
|
||||
const fetchDashboardData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
const siteId = siteFilter === 'all' ? undefined : siteFilter;
|
||||
|
||||
// Fetch pipeline counts sequentially to avoid rate limiting
|
||||
const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const ideasRes = await fetchContentIdeas({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const tasksRes = await fetchTasks({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const contentRes = await fetchContent({ page_size: 1, site_id: siteId });
|
||||
await delay(100);
|
||||
const imagesRes = await fetchContentImages({ page_size: 1, site_id: siteId });
|
||||
// Fetch real dashboard stats from API
|
||||
const stats = await getDashboardStats({
|
||||
site_id: siteId,
|
||||
days: 7
|
||||
});
|
||||
|
||||
const totalKeywords = keywordsRes.count || 0;
|
||||
const totalClusters = clustersRes.count || 0;
|
||||
const totalIdeas = ideasRes.count || 0;
|
||||
const totalTasks = tasksRes.count || 0;
|
||||
const totalContent = contentRes.count || 0;
|
||||
const totalImages = imagesRes.count || 0;
|
||||
const publishedContent = Math.floor(totalContent * 0.6); // Placeholder
|
||||
|
||||
// Calculate completion percentage
|
||||
const completionPercentage = totalKeywords > 0
|
||||
? Math.round((publishedContent / totalKeywords) * 100)
|
||||
// Update pipeline data from real API data
|
||||
const { pipeline, counts } = stats;
|
||||
const completionPercentage = pipeline.keywords > 0
|
||||
? Math.round((pipeline.published / pipeline.keywords) * 100)
|
||||
: 0;
|
||||
|
||||
// Update pipeline data
|
||||
setPipelineData({
|
||||
sites: sites.length,
|
||||
keywords: totalKeywords,
|
||||
clusters: totalClusters,
|
||||
ideas: totalIdeas,
|
||||
tasks: totalTasks,
|
||||
drafts: totalContent,
|
||||
published: publishedContent,
|
||||
sites: pipeline.sites,
|
||||
keywords: pipeline.keywords,
|
||||
clusters: pipeline.clusters,
|
||||
ideas: pipeline.ideas,
|
||||
tasks: pipeline.tasks,
|
||||
drafts: pipeline.drafts,
|
||||
published: pipeline.published,
|
||||
completionPercentage: Math.min(completionPercentage, 100),
|
||||
});
|
||||
|
||||
// Generate attention items based on data
|
||||
// Generate attention items based on real data
|
||||
const attentionList: AttentionItem[] = [];
|
||||
|
||||
// Check for sites without sectors
|
||||
@@ -213,130 +192,85 @@ export default function Home() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check for content needing images
|
||||
const contentWithoutImages = totalContent - Math.floor(totalContent * 0.7);
|
||||
if (contentWithoutImages > 0 && totalContent > 0) {
|
||||
// Check for content needing images (content in review without all images generated)
|
||||
const contentWithPendingImages = counts.images.pending;
|
||||
if (contentWithPendingImages > 0) {
|
||||
attentionList.push({
|
||||
id: 'needs_images',
|
||||
type: 'pending_review',
|
||||
title: 'articles need images',
|
||||
count: contentWithoutImages,
|
||||
title: 'images pending',
|
||||
count: contentWithPendingImages,
|
||||
description: 'Generate images before publishing',
|
||||
actionLabel: 'Generate Images',
|
||||
actionHref: '/writer/images',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for content in review
|
||||
if (counts.content.review > 0) {
|
||||
attentionList.push({
|
||||
id: 'pending_review',
|
||||
type: 'pending_review',
|
||||
title: 'articles ready for review',
|
||||
count: counts.content.review,
|
||||
description: 'Review and publish content',
|
||||
actionLabel: 'Review Content',
|
||||
actionHref: '/writer/content?status=review',
|
||||
});
|
||||
}
|
||||
|
||||
setAttentionItems(attentionList);
|
||||
|
||||
// Update content velocity (using mock calculations based on totals)
|
||||
const weeklyArticles = Math.floor(totalContent * 0.15);
|
||||
const monthlyArticles = Math.floor(totalContent * 0.4);
|
||||
setContentVelocity({
|
||||
thisWeek: {
|
||||
articles: weeklyArticles,
|
||||
words: weeklyArticles * 1500,
|
||||
images: Math.floor(totalImages * 0.15)
|
||||
},
|
||||
thisMonth: {
|
||||
articles: monthlyArticles,
|
||||
words: monthlyArticles * 1500,
|
||||
images: Math.floor(totalImages * 0.4)
|
||||
},
|
||||
total: {
|
||||
articles: totalContent,
|
||||
words: totalContent * 1500,
|
||||
images: totalImages
|
||||
},
|
||||
trend: totalContent > 0 ? Math.floor(Math.random() * 40) - 10 : 0,
|
||||
});
|
||||
// Update content velocity from real API data
|
||||
setContentVelocity(stats.content_velocity);
|
||||
|
||||
// Generate mock recent activity based on actual data
|
||||
const activityList: ActivityItem[] = [];
|
||||
if (totalClusters > 0) {
|
||||
activityList.push({
|
||||
id: 'cluster_1',
|
||||
type: 'clustering',
|
||||
title: `Clustered ${Math.min(45, totalKeywords)} keywords → ${Math.min(8, totalClusters)} clusters`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
href: '/planner/clusters',
|
||||
});
|
||||
}
|
||||
if (totalContent > 0) {
|
||||
activityList.push({
|
||||
id: 'content_1',
|
||||
type: 'content',
|
||||
title: `Generated ${Math.min(5, totalContent)} articles`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000),
|
||||
href: '/writer/content',
|
||||
});
|
||||
}
|
||||
if (totalImages > 0) {
|
||||
activityList.push({
|
||||
id: 'images_1',
|
||||
type: 'images',
|
||||
title: `Created ${Math.min(15, totalImages)} image prompts`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
href: '/writer/images',
|
||||
});
|
||||
}
|
||||
if (publishedContent > 0) {
|
||||
activityList.push({
|
||||
id: 'published_1',
|
||||
type: 'published',
|
||||
title: `Published article to WordPress`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
href: '/writer/published',
|
||||
});
|
||||
}
|
||||
if (totalKeywords > 0) {
|
||||
activityList.push({
|
||||
id: 'keywords_1',
|
||||
type: 'keywords',
|
||||
title: `Added ${Math.min(23, totalKeywords)} keywords`,
|
||||
description: '',
|
||||
timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000),
|
||||
href: '/planner/keywords',
|
||||
});
|
||||
}
|
||||
// Update recent activity from real API data (convert timestamp strings to Date objects)
|
||||
const activityList: ActivityItem[] = stats.recent_activity.map(item => ({
|
||||
...item,
|
||||
timestamp: new Date(item.timestamp),
|
||||
}));
|
||||
setRecentActivity(activityList);
|
||||
|
||||
// Update AI operations (mock data based on content created)
|
||||
const clusteringOps = totalClusters > 0 ? Math.ceil(totalClusters / 3) : 0;
|
||||
const ideasOps = totalIdeas > 0 ? Math.ceil(totalIdeas / 5) : 0;
|
||||
const contentOps = totalContent;
|
||||
const imageOps = totalImages > 0 ? Math.ceil(totalImages / 3) : 0;
|
||||
// Update AI operations from real API data
|
||||
// Map operation types to display types
|
||||
const operationTypeMap: Record<string, string> = {
|
||||
'clustering': 'clustering',
|
||||
'idea_generation': 'ideas',
|
||||
'content_generation': 'content',
|
||||
'image_generation': 'images',
|
||||
'image_prompt_extraction': 'images',
|
||||
};
|
||||
|
||||
const mappedOperations = stats.ai_operations.operations.map(op => ({
|
||||
type: operationTypeMap[op.type] || op.type,
|
||||
count: op.count,
|
||||
credits: op.credits,
|
||||
}));
|
||||
|
||||
// Ensure all expected types exist
|
||||
const expectedTypes = ['clustering', 'ideas', 'content', 'images'];
|
||||
for (const type of expectedTypes) {
|
||||
if (!mappedOperations.find(op => op.type === type)) {
|
||||
mappedOperations.push({ type, count: 0, credits: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
setAIOperations({
|
||||
period: '7d',
|
||||
operations: [
|
||||
{ type: 'clustering', count: clusteringOps, credits: clusteringOps * 10 },
|
||||
{ type: 'ideas', count: ideasOps, credits: ideasOps * 2 },
|
||||
{ type: 'content', count: contentOps, credits: contentOps * 50 },
|
||||
{ type: 'images', count: imageOps, credits: imageOps * 5 },
|
||||
],
|
||||
totals: {
|
||||
count: clusteringOps + ideasOps + contentOps + imageOps,
|
||||
credits: (clusteringOps * 10) + (ideasOps * 2) + (contentOps * 50) + (imageOps * 5),
|
||||
successRate: 98.5,
|
||||
avgCreditsPerOp: contentOps > 0 ? 18.6 : 0,
|
||||
},
|
||||
period: stats.ai_operations.period,
|
||||
operations: mappedOperations,
|
||||
totals: stats.ai_operations.totals,
|
||||
});
|
||||
|
||||
// Set automation status (would come from API in real implementation)
|
||||
// Set automation status (would come from automation API)
|
||||
setAutomationData({
|
||||
status: sites.length > 0 ? 'active' : 'not_configured',
|
||||
schedule: sites.length > 0 ? 'Daily 9 AM' : undefined,
|
||||
lastRun: sites.length > 0 ? {
|
||||
lastRun: sites.length > 0 && counts.content.total > 0 ? {
|
||||
timestamp: new Date(Date.now() - 12 * 60 * 60 * 1000),
|
||||
clustered: Math.min(12, totalKeywords),
|
||||
ideas: Math.min(8, totalIdeas),
|
||||
content: Math.min(5, totalContent),
|
||||
images: Math.min(15, totalImages),
|
||||
clustered: pipeline.clusters,
|
||||
ideas: pipeline.ideas,
|
||||
content: counts.content.total,
|
||||
images: counts.images.total,
|
||||
success: true,
|
||||
} : undefined,
|
||||
nextRun: sites.length > 0 ? new Date(Date.now() + 12 * 60 * 60 * 1000) : undefined,
|
||||
@@ -352,7 +286,7 @@ export default function Home() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteFilter, sites.length, toast]);
|
||||
}, [siteFilter, sites, toast]);
|
||||
|
||||
// Fetch dashboard data when filter changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchClusters,
|
||||
fetchImages,
|
||||
createCluster,
|
||||
updateCluster,
|
||||
deleteCluster,
|
||||
@@ -41,6 +42,7 @@ export default function Clusters() {
|
||||
// Total counts for footer widget (not page-filtered)
|
||||
const [totalWithIdeas, setTotalWithIdeas] = useState(0);
|
||||
const [totalReady, setTotalReady] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -97,6 +99,10 @@ export default function Clusters() {
|
||||
status: 'new',
|
||||
});
|
||||
setTotalReady(newRes.count || 0);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
@@ -184,18 +190,17 @@ export default function Clusters() {
|
||||
};
|
||||
}, [loadClusters]);
|
||||
|
||||
// Debounced search
|
||||
// Debounced search - reset to page 1 when search term changes
|
||||
// Only depend on searchTerm to avoid pagination reset on page navigation
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (currentPage === 1) {
|
||||
loadClusters();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
// Always reset to page 1 when search changes
|
||||
// The main useEffect will handle reloading when currentPage changes
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadClusters]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Reset to page 1 when pageSize changes
|
||||
useEffect(() => {
|
||||
@@ -380,16 +385,43 @@ export default function Clusters() {
|
||||
handleRowAction,
|
||||
]);
|
||||
|
||||
// Calculate header metrics
|
||||
// Calculate header metrics - use totalWithIdeas/totalReady from API calls (not page data)
|
||||
// This ensures metrics show correct totals across all pages, not just current page
|
||||
const headerMetrics = useMemo(() => {
|
||||
if (!pageConfig?.headerMetrics) return [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ clusters, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, clusters, totalCount]);
|
||||
|
||||
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||
return pageConfig.headerMetrics.map((metric) => {
|
||||
let value: number;
|
||||
|
||||
switch (metric.label) {
|
||||
case 'Clusters':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'New':
|
||||
// Use totalReady from loadTotalMetrics() (clusters without ideas)
|
||||
value = totalReady;
|
||||
break;
|
||||
case 'Keywords':
|
||||
// Sum of keywords across all clusters on current page (this is acceptable for display)
|
||||
value = clusters.reduce((sum: number, c) => sum + (c.keywords_count || 0), 0);
|
||||
break;
|
||||
case 'Volume':
|
||||
// Sum of volume across all clusters on current page (this is acceptable for display)
|
||||
value = clusters.reduce((sum: number, c) => sum + (c.total_volume || 0), 0);
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ clusters, totalCount });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
};
|
||||
});
|
||||
}, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
@@ -589,7 +621,7 @@ export default function Clusters() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContentIdeas,
|
||||
fetchImages,
|
||||
createContentIdea,
|
||||
updateContentIdea,
|
||||
deleteContentIdea,
|
||||
@@ -44,6 +45,7 @@ export default function Ideas() {
|
||||
// Total counts for footer widget (not page-filtered)
|
||||
const [totalInTasks, setTotalInTasks] = useState(0);
|
||||
const [totalPending, setTotalPending] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -117,6 +119,10 @@ export default function Ideas() {
|
||||
status: 'new',
|
||||
});
|
||||
setTotalPending(newRes.count || 0);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
@@ -189,18 +195,17 @@ export default function Ideas() {
|
||||
setCurrentPage(1);
|
||||
}, [pageSize]);
|
||||
|
||||
// Debounced search
|
||||
// Debounced search - reset to page 1 when search term changes
|
||||
// Only depend on searchTerm to avoid pagination reset on page navigation
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (currentPage === 1) {
|
||||
loadIdeas();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
// Always reset to page 1 when search changes
|
||||
// The main useEffect will handle reloading when currentPage changes
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadIdeas]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||
@@ -289,16 +294,43 @@ export default function Ideas() {
|
||||
});
|
||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
|
||||
|
||||
// Calculate header metrics
|
||||
// Calculate header metrics - use totalInTasks/totalPending from API calls (not page data)
|
||||
// This ensures metrics show correct totals across all pages, not just current page
|
||||
const headerMetrics = useMemo(() => {
|
||||
if (!pageConfig?.headerMetrics) return [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ ideas, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, ideas, totalCount]);
|
||||
|
||||
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||
return pageConfig.headerMetrics.map((metric) => {
|
||||
let value: number;
|
||||
|
||||
switch (metric.label) {
|
||||
case 'Ideas':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'New':
|
||||
// Use totalPending from loadTotalMetrics() (ideas with status='new')
|
||||
value = totalPending;
|
||||
break;
|
||||
case 'Queued':
|
||||
// Use totalInTasks from loadTotalMetrics() (ideas with status='queued')
|
||||
value = totalInTasks;
|
||||
break;
|
||||
case 'Completed':
|
||||
// Calculate completed from totalCount - (totalPending + totalInTasks)
|
||||
value = Math.max(0, totalCount - totalPending - totalInTasks);
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ ideas, totalCount });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
};
|
||||
});
|
||||
}, [pageConfig?.headerMetrics, ideas, totalCount, totalPending, totalInTasks]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
@@ -522,7 +554,7 @@ export default function Ideas() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchKeywords,
|
||||
fetchImages,
|
||||
createKeyword,
|
||||
updateKeyword,
|
||||
deleteKeyword,
|
||||
@@ -50,6 +51,7 @@ export default function Keywords() {
|
||||
const [totalClustered, setTotalClustered] = useState(0);
|
||||
const [totalUnmapped, setTotalUnmapped] = useState(0);
|
||||
const [totalVolume, setTotalVolume] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state - match Keywords.tsx
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -141,6 +143,10 @@ export default function Keywords() {
|
||||
// For now, we'll just calculate from current data or set to 0
|
||||
// TODO: Backend should provide total volume as an aggregated metric
|
||||
setTotalVolume(0);
|
||||
|
||||
// Get actual total images count
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading total metrics:', error);
|
||||
}
|
||||
@@ -524,16 +530,43 @@ export default function Keywords() {
|
||||
activeSite,
|
||||
]);
|
||||
|
||||
// Calculate header metrics from config (matching reference plugin KPIs from kpi-config.php)
|
||||
// Calculate header metrics - use totalClustered/totalUnmapped from API calls (not page data)
|
||||
// This ensures metrics show correct totals across all pages, not just current page
|
||||
const headerMetrics = useMemo(() => {
|
||||
if (!pageConfig?.headerMetrics) return [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ keywords, totalCount, clusters }),
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip, // Add tooltip support
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters]);
|
||||
|
||||
// Override the calculate function to use pre-loaded totals instead of filtering page data
|
||||
return pageConfig.headerMetrics.map((metric) => {
|
||||
let value: number;
|
||||
|
||||
switch (metric.label) {
|
||||
case 'Keywords':
|
||||
value = totalCount || 0;
|
||||
break;
|
||||
case 'Clustered':
|
||||
// Use totalClustered from loadTotalMetrics() instead of filtering page data
|
||||
value = totalClustered;
|
||||
break;
|
||||
case 'Unmapped':
|
||||
// Use totalUnmapped from loadTotalMetrics() instead of filtering page data
|
||||
value = totalUnmapped;
|
||||
break;
|
||||
case 'Volume':
|
||||
// Use totalVolume from loadTotalMetrics() (if implemented) or keep original
|
||||
value = totalVolume || keywords.reduce((sum: number, k) => sum + (k.volume || 0), 0);
|
||||
break;
|
||||
default:
|
||||
value = metric.calculate({ keywords, totalCount, clusters });
|
||||
}
|
||||
|
||||
return {
|
||||
label: metric.label,
|
||||
value,
|
||||
accentColor: metric.accentColor,
|
||||
tooltip: (metric as any).tooltip,
|
||||
};
|
||||
});
|
||||
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters, totalClustered, totalUnmapped, totalVolume]);
|
||||
|
||||
// Calculate workflow insights based on UX doc principles
|
||||
const workflowStats = useMemo(() => {
|
||||
@@ -819,7 +852,7 @@ export default function Keywords() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
Content,
|
||||
ContentListResponse,
|
||||
ContentFilters,
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContent,
|
||||
fetchImages,
|
||||
Content,
|
||||
ContentListResponse,
|
||||
ContentFilters,
|
||||
@@ -31,6 +32,7 @@ export default function Review() {
|
||||
// Data state
|
||||
const [content, setContent] = useState<Content[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state - default to review status
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -83,6 +85,19 @@ export default function Review() {
|
||||
loadContent();
|
||||
}, [loadContent]);
|
||||
|
||||
// Load total images count
|
||||
useEffect(() => {
|
||||
const loadImageCount = async () => {
|
||||
try {
|
||||
const imagesRes = await fetchImages({ page_size: 1 });
|
||||
setTotalImagesCount(imagesRes.count || 0);
|
||||
} catch (error) {
|
||||
console.error('Error loading image count:', error);
|
||||
}
|
||||
};
|
||||
loadImageCount();
|
||||
}, []);
|
||||
|
||||
// Listen for site and sector changes and refresh data
|
||||
useEffect(() => {
|
||||
const handleSiteChange = () => {
|
||||
@@ -494,7 +509,7 @@ export default function Review() {
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: 0,
|
||||
toValue: totalImagesCount,
|
||||
toHref: '/writer/images',
|
||||
progress: 0,
|
||||
color: 'purple',
|
||||
@@ -527,7 +542,7 @@ export default function Review() {
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
||||
{ label: 'Images Created', value: 0, color: 'purple' },
|
||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||
{ label: 'Articles Published', value: 0, color: 'green' },
|
||||
],
|
||||
analyticsHref: '/account/usage',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Link } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchTasks,
|
||||
fetchImages,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
@@ -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',
|
||||
|
||||
@@ -1042,3 +1042,90 @@ export async function getPublicPlans(): Promise<Plan[]> {
|
||||
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||
return fetchAPI('/v1/billing/usage-summary/');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DASHBOARD STATS (Real data for home page)
|
||||
// ============================================================================
|
||||
|
||||
export interface DashboardAIOperation {
|
||||
type: string;
|
||||
count: number;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface DashboardAIOperations {
|
||||
period: string;
|
||||
operations: DashboardAIOperation[];
|
||||
totals: {
|
||||
count: number;
|
||||
credits: number;
|
||||
successRate: number;
|
||||
avgCreditsPerOp: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface DashboardContentVelocity {
|
||||
thisWeek: {
|
||||
articles: number;
|
||||
words: number;
|
||||
images: number;
|
||||
};
|
||||
thisMonth: {
|
||||
articles: number;
|
||||
words: number;
|
||||
images: number;
|
||||
};
|
||||
total: {
|
||||
articles: number;
|
||||
words: number;
|
||||
images: number;
|
||||
};
|
||||
trend: number;
|
||||
}
|
||||
|
||||
export interface DashboardPipeline {
|
||||
sites: number;
|
||||
keywords: number;
|
||||
clusters: number;
|
||||
ideas: number;
|
||||
tasks: number;
|
||||
drafts: number;
|
||||
published: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
ai_operations: DashboardAIOperations;
|
||||
recent_activity: DashboardActivityItem[];
|
||||
content_velocity: DashboardContentVelocity;
|
||||
pipeline: DashboardPipeline;
|
||||
counts: {
|
||||
content: {
|
||||
total: number;
|
||||
draft: number;
|
||||
review: number;
|
||||
published: number;
|
||||
};
|
||||
images: {
|
||||
total: number;
|
||||
generated: number;
|
||||
pending: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardStats(params?: { site_id?: number; days?: number }): Promise<DashboardStats> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.site_id) searchParams.append('site_id', params.site_id.toString());
|
||||
if (params?.days) searchParams.append('days', params.days.toString());
|
||||
const query = searchParams.toString();
|
||||
return fetchAPI(`/v1/account/dashboard/stats/${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user