final plolish phase 2
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
**Date:** December 27, 2025
|
||||
**Scope:** Complete application audit for optimal user experience
|
||||
**Note:** Plans, billing, credits, usage sections excluded - will be done in separate phase
|
||||
**Status:** ✅ IMPLEMENTED
|
||||
**Status:** ✅ IMPLEMENTED & INTEGRATED
|
||||
|
||||
---
|
||||
|
||||
@@ -12,13 +12,23 @@
|
||||
| Section | Status | Files Modified |
|
||||
|---------|--------|----------------|
|
||||
| 1. Site & Sector Selector | ✅ | Already implemented per guidelines |
|
||||
| 2. Tooltip Improvements | ✅ | `config/pages/*.config.tsx` (fixed typo) |
|
||||
| 2. Tooltip Improvements | ✅ | `config/pages/*.config.tsx` (all 8 page configs updated with actionable tooltips) |
|
||||
| 3. Footer 3-Widget Layout | ✅ | `components/dashboard/ThreeWidgetFooter.tsx` |
|
||||
| 4. Progress Modal Steps | ✅ | `backend/igny8_core/ai/engine.py` |
|
||||
| 5. Dashboard Redesign | ✅ | `components/dashboard/CompactDashboard.tsx` |
|
||||
| 6. Site Setup Checklist | ✅ | `components/common/SiteCard.tsx`, `backend/auth/serializers.py`, `services/api.ts` |
|
||||
| 7. To-Do-s Audit | ✅ | Documentation only |
|
||||
| 8. Notification System | ✅ | `store/notificationStore.ts`, `components/header/NotificationDropdownNew.tsx` |
|
||||
| 8. Notification System | ✅ | `store/notificationStore.ts`, `components/header/NotificationDropdownNew.tsx`, `hooks/useProgressModal.ts` |
|
||||
|
||||
### Integration Complete
|
||||
|
||||
| Integration | Status | Details |
|
||||
|-------------|--------|---------|
|
||||
| NotificationDropdown → AppHeader | ✅ | `layout/AppHeader.tsx`, `components/header/Header.tsx` now use `NotificationDropdownNew` |
|
||||
| AI Task → Notifications | ✅ | `hooks/useProgressModal.ts` automatically adds notifications on success/failure |
|
||||
| Dashboard exports | ✅ | `components/dashboard/index.ts` barrel export created |
|
||||
| NeedsAttentionBar → Home | ✅ | `pages/Dashboard/Home.tsx` shows attention items at top |
|
||||
| ThreeWidgetFooter hook | ✅ | `hooks/useThreeWidgetFooter.ts` helper for easy integration |
|
||||
|
||||
---
|
||||
|
||||
|
||||
381
backend/igny8_core/api/dashboard_views.py
Normal file
381
backend/igny8_core/api/dashboard_views.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Dashboard API Views
|
||||
Provides aggregated data for the frontend dashboard in a single call.
|
||||
Replaces multiple sequential API calls for better performance.
|
||||
"""
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Count, Sum, Q, F
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
|
||||
from igny8_core.auth.models import Site, Sector
|
||||
from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas
|
||||
from igny8_core.business.content.models import Tasks, Content
|
||||
from igny8_core.business.billing.models import CreditUsageLog
|
||||
from igny8_core.ai.models import AITaskLog
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
summary=extend_schema(
|
||||
tags=['Dashboard'],
|
||||
summary='Get dashboard summary',
|
||||
description='Returns aggregated dashboard data including pipeline counts, AI operations, recent activity, and items needing attention.',
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='site_id',
|
||||
description='Filter by specific site ID',
|
||||
required=False,
|
||||
type=int
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='days',
|
||||
description='Number of days for recent activity and AI operations (default: 7)',
|
||||
required=False,
|
||||
type=int
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
class DashboardSummaryViewSet(viewsets.ViewSet):
|
||||
"""Dashboard summary providing aggregated data for the main dashboard."""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def summary(self, request):
|
||||
"""
|
||||
Get comprehensive dashboard summary in a single API call.
|
||||
|
||||
Returns:
|
||||
- needs_attention: Items requiring user action
|
||||
- pipeline: Workflow pipeline counts (keywords → published)
|
||||
- ai_operations: Recent AI usage stats
|
||||
- recent_activity: Latest activity log
|
||||
- content_velocity: Content creation trends
|
||||
- automation: Automation status summary
|
||||
"""
|
||||
account = request.user.account
|
||||
site_id = request.query_params.get('site_id')
|
||||
days = int(request.query_params.get('days', 7))
|
||||
start_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Build base filters
|
||||
site_filter = Q(site__account=account)
|
||||
if site_id:
|
||||
site_filter &= Q(site_id=site_id)
|
||||
|
||||
# ==========================================
|
||||
# 1. PIPELINE COUNTS
|
||||
# ==========================================
|
||||
keywords_count = Keywords.objects.filter(site_filter).count()
|
||||
clusters_count = Clusters.objects.filter(site_filter).count()
|
||||
ideas_count = ContentIdeas.objects.filter(site_filter).count()
|
||||
tasks_count = Tasks.objects.filter(site_filter).count()
|
||||
|
||||
content_filter = site_filter
|
||||
drafts_count = Content.objects.filter(content_filter, status='draft').count()
|
||||
review_count = Content.objects.filter(content_filter, status='review').count()
|
||||
published_count = Content.objects.filter(content_filter, status='published').count()
|
||||
total_content = drafts_count + review_count + published_count
|
||||
|
||||
# Calculate completion percentage based on workflow milestones
|
||||
milestones = [
|
||||
keywords_count > 0,
|
||||
clusters_count > 0,
|
||||
ideas_count > 0,
|
||||
tasks_count > 0,
|
||||
total_content > 0,
|
||||
published_count > 0,
|
||||
]
|
||||
completion_percentage = int((sum(milestones) / len(milestones)) * 100) if milestones else 0
|
||||
|
||||
pipeline = {
|
||||
'keywords': keywords_count,
|
||||
'clusters': clusters_count,
|
||||
'ideas': ideas_count,
|
||||
'tasks': tasks_count,
|
||||
'drafts': drafts_count,
|
||||
'review': review_count,
|
||||
'published': published_count,
|
||||
'total_content': total_content,
|
||||
'completion_percentage': completion_percentage,
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 2. NEEDS ATTENTION
|
||||
# ==========================================
|
||||
needs_attention = []
|
||||
|
||||
# Content pending review
|
||||
if review_count > 0:
|
||||
needs_attention.append({
|
||||
'id': 'pending-review',
|
||||
'type': 'pending_review',
|
||||
'title': 'pending review',
|
||||
'count': review_count,
|
||||
'action_label': 'Review',
|
||||
'action_url': '/writer/review',
|
||||
'severity': 'warning',
|
||||
})
|
||||
|
||||
# Sites without keywords (incomplete setup)
|
||||
sites = Site.objects.filter(account=account, is_active=True)
|
||||
sites_without_keywords = []
|
||||
for site in sites:
|
||||
kw_count = Keywords.objects.filter(site=site).count()
|
||||
if kw_count == 0:
|
||||
sites_without_keywords.append(site)
|
||||
|
||||
if sites_without_keywords:
|
||||
if len(sites_without_keywords) == 1:
|
||||
needs_attention.append({
|
||||
'id': 'setup-incomplete',
|
||||
'type': 'setup_incomplete',
|
||||
'title': f'{sites_without_keywords[0].name} needs setup',
|
||||
'action_label': 'Complete',
|
||||
'action_url': f'/sites/{sites_without_keywords[0].id}',
|
||||
'severity': 'info',
|
||||
})
|
||||
else:
|
||||
needs_attention.append({
|
||||
'id': 'setup-incomplete',
|
||||
'type': 'setup_incomplete',
|
||||
'title': f'{len(sites_without_keywords)} sites need setup',
|
||||
'action_label': 'Complete',
|
||||
'action_url': '/sites',
|
||||
'severity': 'info',
|
||||
})
|
||||
|
||||
# Sites without integrations
|
||||
sites_without_integration = sites.filter(has_integration=False).count()
|
||||
if sites_without_integration > 0:
|
||||
needs_attention.append({
|
||||
'id': 'no-integration',
|
||||
'type': 'no_integration',
|
||||
'title': f'{sites_without_integration} site{"s" if sites_without_integration > 1 else ""} without WordPress',
|
||||
'action_label': 'Connect',
|
||||
'action_url': '/integrations',
|
||||
'severity': 'info',
|
||||
})
|
||||
|
||||
# Low credits warning
|
||||
if account.credits < 100:
|
||||
needs_attention.append({
|
||||
'id': 'credits-low',
|
||||
'type': 'credits_low',
|
||||
'title': f'Credits running low ({account.credits} remaining)',
|
||||
'action_label': 'Upgrade',
|
||||
'action_url': '/billing/plans',
|
||||
'severity': 'warning' if account.credits > 20 else 'error',
|
||||
})
|
||||
|
||||
# Queued tasks not processed
|
||||
queued_tasks = Tasks.objects.filter(site_filter, status='queued').count()
|
||||
if queued_tasks > 10:
|
||||
needs_attention.append({
|
||||
'id': 'queued-tasks',
|
||||
'type': 'queued_tasks',
|
||||
'title': f'{queued_tasks} tasks waiting to be generated',
|
||||
'action_label': 'Generate',
|
||||
'action_url': '/writer/tasks',
|
||||
'severity': 'info',
|
||||
})
|
||||
|
||||
# ==========================================
|
||||
# 3. AI OPERATIONS (last N days)
|
||||
# ==========================================
|
||||
ai_usage = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
created_at__gte=start_date
|
||||
)
|
||||
|
||||
# Group by operation type
|
||||
operations_by_type = ai_usage.values('operation_type').annotate(
|
||||
count=Count('id'),
|
||||
credits=Sum('credits_used'),
|
||||
tokens=Sum('tokens_input') + Sum('tokens_output')
|
||||
).order_by('-count')
|
||||
|
||||
# Format operation names
|
||||
operation_display = {
|
||||
'clustering': 'Clustering',
|
||||
'idea_generation': 'Ideas',
|
||||
'content_generation': 'Content',
|
||||
'image_generation': 'Images',
|
||||
'image_prompt_extraction': 'Image Prompts',
|
||||
'linking': 'Linking',
|
||||
'optimization': 'Optimization',
|
||||
'reparse': 'Reparse',
|
||||
'site_page_generation': 'Site Pages',
|
||||
'site_structure_generation': 'Site Structure',
|
||||
'ideas': 'Ideas',
|
||||
'content': 'Content',
|
||||
'images': 'Images',
|
||||
}
|
||||
|
||||
operations = []
|
||||
for op in operations_by_type[:5]: # Top 5 operations
|
||||
operations.append({
|
||||
'type': op['operation_type'],
|
||||
'label': operation_display.get(op['operation_type'], op['operation_type'].replace('_', ' ').title()),
|
||||
'count': op['count'],
|
||||
'credits': op['credits'] or 0,
|
||||
'tokens': op['tokens'] or 0,
|
||||
})
|
||||
|
||||
total_credits_used = ai_usage.aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
total_operations = ai_usage.count()
|
||||
|
||||
ai_operations = {
|
||||
'period_days': days,
|
||||
'operations': operations,
|
||||
'totals': {
|
||||
'credits': total_credits_used,
|
||||
'operations': total_operations,
|
||||
}
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 4. RECENT ACTIVITY
|
||||
# ==========================================
|
||||
recent_logs = AITaskLog.objects.filter(
|
||||
account=account,
|
||||
status='success',
|
||||
created_at__gte=start_date
|
||||
).order_by('-created_at')[:10]
|
||||
|
||||
activity_icons = {
|
||||
'run_clustering': 'group',
|
||||
'generate_content_ideas': 'bolt',
|
||||
'generate_content': 'file-text',
|
||||
'generate_images': 'image',
|
||||
'publish_content': 'paper-plane',
|
||||
'optimize_content': 'sparkles',
|
||||
'link_content': 'link',
|
||||
}
|
||||
|
||||
activity_colors = {
|
||||
'run_clustering': 'purple',
|
||||
'generate_content_ideas': 'orange',
|
||||
'generate_content': 'blue',
|
||||
'generate_images': 'pink',
|
||||
'publish_content': 'green',
|
||||
'optimize_content': 'cyan',
|
||||
'link_content': 'indigo',
|
||||
}
|
||||
|
||||
recent_activity = []
|
||||
for log in recent_logs:
|
||||
# Parse friendly message from the log
|
||||
message = log.message or f'{log.function_name} completed'
|
||||
|
||||
recent_activity.append({
|
||||
'id': log.id,
|
||||
'type': log.function_name,
|
||||
'description': message,
|
||||
'timestamp': log.created_at.isoformat(),
|
||||
'icon': activity_icons.get(log.function_name, 'bolt'),
|
||||
'color': activity_colors.get(log.function_name, 'gray'),
|
||||
'credits': float(log.cost) if log.cost else 0,
|
||||
})
|
||||
|
||||
# ==========================================
|
||||
# 5. CONTENT VELOCITY
|
||||
# ==========================================
|
||||
# Content created in different periods
|
||||
now = timezone.now()
|
||||
content_today = Content.objects.filter(
|
||||
content_filter,
|
||||
created_at__date=now.date()
|
||||
).count()
|
||||
|
||||
content_this_week = Content.objects.filter(
|
||||
content_filter,
|
||||
created_at__gte=now - timedelta(days=7)
|
||||
).count()
|
||||
|
||||
content_this_month = Content.objects.filter(
|
||||
content_filter,
|
||||
created_at__gte=now - timedelta(days=30)
|
||||
).count()
|
||||
|
||||
# Daily breakdown for last 7 days
|
||||
daily_content = []
|
||||
for i in range(7):
|
||||
day = now - timedelta(days=6-i)
|
||||
count = Content.objects.filter(
|
||||
content_filter,
|
||||
created_at__date=day.date()
|
||||
).count()
|
||||
daily_content.append({
|
||||
'date': day.date().isoformat(),
|
||||
'count': count,
|
||||
})
|
||||
|
||||
content_velocity = {
|
||||
'today': content_today,
|
||||
'this_week': content_this_week,
|
||||
'this_month': content_this_month,
|
||||
'daily': daily_content,
|
||||
'average_per_day': round(content_this_week / 7, 1) if content_this_week else 0,
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 6. AUTOMATION STATUS
|
||||
# ==========================================
|
||||
# Check automation settings
|
||||
from igny8_core.business.automation.models import AutomationSettings
|
||||
|
||||
automation_enabled = AutomationSettings.objects.filter(
|
||||
account=account,
|
||||
enabled=True
|
||||
).exists()
|
||||
|
||||
active_automations = AutomationSettings.objects.filter(
|
||||
account=account,
|
||||
enabled=True
|
||||
).count()
|
||||
|
||||
automation = {
|
||||
'enabled': automation_enabled,
|
||||
'active_count': active_automations,
|
||||
'status': 'active' if automation_enabled else 'inactive',
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 7. SITES SUMMARY
|
||||
# ==========================================
|
||||
sites_data = []
|
||||
for site in sites[:5]: # Top 5 sites
|
||||
site_keywords = Keywords.objects.filter(site=site).count()
|
||||
site_content = Content.objects.filter(site=site).count()
|
||||
site_published = Content.objects.filter(site=site, status='published').count()
|
||||
|
||||
sites_data.append({
|
||||
'id': site.id,
|
||||
'name': site.name,
|
||||
'domain': site.url,
|
||||
'keywords': site_keywords,
|
||||
'content': site_content,
|
||||
'published': site_published,
|
||||
'has_integration': site.has_integration,
|
||||
'sectors_count': site.sectors.filter(is_active=True).count(),
|
||||
})
|
||||
|
||||
return Response({
|
||||
'needs_attention': needs_attention,
|
||||
'pipeline': pipeline,
|
||||
'ai_operations': ai_operations,
|
||||
'recent_activity': recent_activity,
|
||||
'content_velocity': content_velocity,
|
||||
'automation': automation,
|
||||
'sites': sites_data,
|
||||
'account': {
|
||||
'credits': account.credits,
|
||||
'name': account.name,
|
||||
},
|
||||
'generated_at': timezone.now().isoformat(),
|
||||
})
|
||||
@@ -8,6 +8,7 @@ from .account_views import (
|
||||
TeamManagementViewSet,
|
||||
UsageAnalyticsViewSet
|
||||
)
|
||||
from .dashboard_views import DashboardSummaryViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -22,5 +23,8 @@ urlpatterns = [
|
||||
# Usage analytics
|
||||
path('usage/analytics/', UsageAnalyticsViewSet.as_view({'get': 'overview'}), name='usage-analytics'),
|
||||
|
||||
# Dashboard summary (aggregated data for main dashboard)
|
||||
path('dashboard/summary/', DashboardSummaryViewSet.as_view({'get': 'summary'}), name='dashboard-summary'),
|
||||
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
/**
|
||||
* ModuleMetricsFooter - Compact metrics footer for table pages
|
||||
* Shows module-specific metrics at the bottom of table pages
|
||||
* Uses standard EnhancedMetricCard and ProgressBar components
|
||||
* Follows standard app design system and color scheme
|
||||
*
|
||||
* Supports two layouts:
|
||||
* 1. Default: Grid of MetricCards + optional single progress bar
|
||||
* 2. ThreeWidget: 3-column layout (Page Progress | Module Stats | Completion)
|
||||
* - Matches Section 3 of COMPREHENSIVE-AUDIT-REPORT.md exactly
|
||||
*
|
||||
* STYLING: Uses CSS tokens from styles/tokens.css:
|
||||
* - --color-primary: Brand blue for primary actions/bars
|
||||
* - --color-success: Green for success states
|
||||
* - --color-warning: Amber for warnings
|
||||
* - --color-purple: Purple accent
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import EnhancedMetricCard, { MetricCardProps } from './EnhancedMetricCard';
|
||||
import { ProgressBar } from '../ui/progress';
|
||||
import { Card } from '../ui/card/Card';
|
||||
import { LightBulbIcon, ChevronRightIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
export interface MetricItem {
|
||||
title: string;
|
||||
@@ -25,30 +37,108 @@ export interface ProgressMetric {
|
||||
color?: 'primary' | 'success' | 'warning' | 'purple';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// THREE-WIDGET LAYOUT TYPES (Section 3 of Audit Report)
|
||||
// ============================================================================
|
||||
|
||||
/** Submodule color type - matches headerMetrics accentColor */
|
||||
export type SubmoduleColor = 'blue' | 'green' | 'amber' | 'purple';
|
||||
|
||||
/** Widget 1: Page Progress - metrics in 2x2 grid + progress bar + hint */
|
||||
export interface PageProgressWidget {
|
||||
title: string;
|
||||
metrics: Array<{ label: string; value: string | number; percentage?: string }>;
|
||||
progress: { value: number; label: string; color?: SubmoduleColor };
|
||||
hint?: string;
|
||||
/** The submodule's accent color - progress bar uses this */
|
||||
submoduleColor?: SubmoduleColor;
|
||||
}
|
||||
|
||||
/** Widget 2: Module Stats - Pipeline flow with arrows and progress bars */
|
||||
export interface ModulePipelineRow {
|
||||
fromLabel: string;
|
||||
fromValue: number;
|
||||
fromHref?: string;
|
||||
actionLabel: string;
|
||||
toLabel: string;
|
||||
toValue: number;
|
||||
toHref?: string;
|
||||
progress: number; // 0-100
|
||||
/** Color for this pipeline row's progress bar */
|
||||
color?: SubmoduleColor;
|
||||
}
|
||||
|
||||
export interface ModuleStatsWidget {
|
||||
title: string;
|
||||
pipeline: ModulePipelineRow[];
|
||||
links: Array<{ label: string; href: string }>;
|
||||
}
|
||||
|
||||
/** Widget 3: Completion - Tree structure with bars for both modules */
|
||||
export interface CompletionItem {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: SubmoduleColor;
|
||||
}
|
||||
|
||||
export interface CompletionWidget {
|
||||
title: string;
|
||||
plannerItems: CompletionItem[];
|
||||
writerItems: CompletionItem[];
|
||||
creditsUsed?: number;
|
||||
operationsCount?: number;
|
||||
analyticsHref?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPONENT PROPS
|
||||
// ============================================================================
|
||||
|
||||
interface ModuleMetricsFooterProps {
|
||||
metrics: MetricItem[];
|
||||
metrics?: MetricItem[];
|
||||
progress?: ProgressMetric;
|
||||
className?: string;
|
||||
/** Submodule accent color - used for progress bars when in threeWidgetLayout */
|
||||
submoduleColor?: SubmoduleColor;
|
||||
threeWidgetLayout?: {
|
||||
pageProgress: PageProgressWidget;
|
||||
moduleStats: ModuleStatsWidget;
|
||||
completion: CompletionWidget;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ModuleMetricsFooter({
|
||||
metrics,
|
||||
metrics = [],
|
||||
progress,
|
||||
className = ''
|
||||
className = '',
|
||||
submoduleColor = 'blue',
|
||||
threeWidgetLayout,
|
||||
}: ModuleMetricsFooterProps) {
|
||||
|
||||
// Three-widget layout:
|
||||
// First 2 widgets = 50% (25% each), Last widget = 50% with 2 columns inside
|
||||
if (threeWidgetLayout) {
|
||||
return (
|
||||
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left side: 2 widgets side by side (each 50% of 50% = 25% total) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<PageProgressCard widget={threeWidgetLayout.pageProgress} submoduleColor={submoduleColor} />
|
||||
<ModuleStatsCard widget={threeWidgetLayout.moduleStats} />
|
||||
</div>
|
||||
{/* Right side: Completion widget (50% of total, 2 columns inside) */}
|
||||
<CompletionCard widget={threeWidgetLayout.completion} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Original layout (default)
|
||||
if (metrics.length === 0 && !progress) return null;
|
||||
|
||||
const progressColors = {
|
||||
primary: 'bg-[var(--color-primary)]',
|
||||
success: 'bg-[var(--color-success)]',
|
||||
warning: 'bg-[var(--color-warning)]',
|
||||
purple: 'bg-[var(--color-purple)]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-800 ${className}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Metrics Grid */}
|
||||
{metrics.length > 0 && (
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 ${metrics.length > 2 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} ${metrics.length > 3 ? 'xl:grid-cols-4' : ''} gap-4`}>
|
||||
{metrics.map((metric, index) => (
|
||||
@@ -65,8 +155,6 @@ export default function ModuleMetricsFooter({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{progress && (
|
||||
<div className="space-y-2">
|
||||
<ProgressBar
|
||||
@@ -83,3 +171,319 @@ export default function ModuleMetricsFooter({
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLOR UTILITIES - Maps SubmoduleColor to CSS token classes
|
||||
// Uses CSS variables from styles/tokens.css
|
||||
// ============================================================================
|
||||
|
||||
const getProgressBarStyle = (color: SubmoduleColor = 'blue'): React.CSSProperties => {
|
||||
const colorMap: Record<SubmoduleColor, string> = {
|
||||
blue: 'var(--color-primary)',
|
||||
green: 'var(--color-success)',
|
||||
amber: 'var(--color-warning)',
|
||||
purple: 'var(--color-purple)',
|
||||
};
|
||||
return { backgroundColor: colorMap[color] };
|
||||
};
|
||||
|
||||
const getLinkColorClass = (color: SubmoduleColor = 'blue'): string => {
|
||||
// Using CSS variable approach for brand consistency
|
||||
return 'text-[color:var(--color-primary)] hover:text-[color:var(--color-primary-dark)]';
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 1: PAGE PROGRESS
|
||||
// Design from audit:
|
||||
// ┌──────────────────────────────────────────────────┐
|
||||
// │ PAGE PROGRESS │
|
||||
// │ │
|
||||
// │ Clusters 12 With Ideas 8 (67%) │
|
||||
// │ Keywords 46 Ready 4 │
|
||||
// │ │
|
||||
// │ ██████████████░░░░░░░ 67% Have Ideas │
|
||||
// │ │
|
||||
// │ 💡 4 clusters ready for idea generation │
|
||||
// └──────────────────────────────────────────────────┘
|
||||
// ============================================================================
|
||||
|
||||
function PageProgressCard({ widget, submoduleColor = 'blue' }: { widget: PageProgressWidget; submoduleColor?: SubmoduleColor }) {
|
||||
const progressColor = widget.submoduleColor || widget.progress.color || submoduleColor;
|
||||
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* 2x2 Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mb-5">
|
||||
{widget.metrics.slice(0, 4).map((metric, idx) => (
|
||||
<div key={idx} className="flex items-baseline justify-between">
|
||||
<span className="text-sm text-[color:var(--color-text-dim)] dark:text-gray-400">{metric.label}</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-lg font-bold text-[color:var(--color-text)] dark:text-white tabular-nums">
|
||||
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
|
||||
</span>
|
||||
{metric.percentage && (
|
||||
<span className="text-xs text-[color:var(--color-text-dim)] dark:text-gray-400">({metric.percentage})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - uses submodule color */}
|
||||
<div className="mb-4">
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
...getProgressBarStyle(progressColor),
|
||||
width: `${Math.min(100, Math.max(0, widget.progress.value))}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{widget.progress.label}</span>
|
||||
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white">{widget.progress.value}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint with icon (no emoji) */}
|
||||
{widget.hint && (
|
||||
<div className="flex items-start gap-2 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
<LightBulbIcon className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-warning)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-primary)' }}>{widget.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 2: MODULE STATS
|
||||
// Design from audit:
|
||||
// ┌──────────────────────────────────────────────────┐
|
||||
// │ PLANNER MODULE │
|
||||
// │ │
|
||||
// │ Keywords 46 ► Clusters 12 │
|
||||
// │ ████████████████████░░░ 91% │
|
||||
// │ │
|
||||
// │ [→ Keywords] [→ Clusters] [→ Ideas] │
|
||||
// └──────────────────────────────────────────────────┘
|
||||
// ============================================================================
|
||||
|
||||
function ModuleStatsCard({ widget }: { widget: ModuleStatsWidget }) {
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* Pipeline Rows */}
|
||||
<div className="space-y-4 mb-4">
|
||||
{widget.pipeline.map((row, idx) => (
|
||||
<div key={idx}>
|
||||
{/* Row header: FromLabel Value ► ToLabel Value */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{/* From side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{row.fromHref ? (
|
||||
<Link
|
||||
to={row.fromHref}
|
||||
className="text-sm font-medium hover:underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
{row.fromLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{row.fromLabel}</span>
|
||||
)}
|
||||
<span className="text-lg font-bold tabular-nums" style={{ color: 'var(--color-primary)' }}>
|
||||
{row.fromValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow icon - clean chevron, just the tip */}
|
||||
<ChevronRightIcon
|
||||
className="w-6 h-6 flex-shrink-0 mx-2"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
/>
|
||||
|
||||
{/* To side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{row.toHref ? (
|
||||
<Link
|
||||
to={row.toHref}
|
||||
className="text-sm font-medium hover:underline"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
{row.toLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{row.toLabel}</span>
|
||||
)}
|
||||
<span className="text-lg font-bold text-[color:var(--color-text)] dark:text-white tabular-nums">
|
||||
{row.toValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar - uses row color or default primary */}
|
||||
<div className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
...getProgressBarStyle(row.color || 'blue'),
|
||||
width: `${Math.min(100, Math.max(0, row.progress))}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex flex-wrap gap-3 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
{widget.links.map((link, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
to={link.href}
|
||||
className="text-sm font-medium hover:underline flex items-center gap-1"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 3: COMPLETION
|
||||
// Design from audit - with 2 COLUMNS (Planner | Writer) side by side:
|
||||
// ┌──────────────────────────────────────────────────────────────────┐
|
||||
// │ WORKFLOW COMPLETION │
|
||||
// │ │
|
||||
// │ PLANNER │ WRITER │
|
||||
// │ ├─ Keywords Clustered 42 │ ├─ Content Generated 28 │
|
||||
// │ ├─ Clusters Created 12 │ ├─ Images Created 127 │
|
||||
// │ └─ Ideas Generated 34 │ └─ Articles Published 45 │
|
||||
// │ │
|
||||
// │ Credits Used: 2,450 │ Operations: 156 │
|
||||
// │ │
|
||||
// │ [View Full Analytics →] │
|
||||
// └──────────────────────────────────────────────────────────────────┘
|
||||
// ============================================================================
|
||||
|
||||
function CompletionCard({ widget }: { widget: CompletionWidget }) {
|
||||
// Calculate max for proportional bars (across both columns)
|
||||
const allValues = [...widget.plannerItems, ...widget.writerItems].map(i => i.value);
|
||||
const maxValue = Math.max(...allValues, 1);
|
||||
|
||||
const renderItem = (item: CompletionItem, isLast: boolean) => {
|
||||
const barWidth = (item.value / maxValue) * 100;
|
||||
const prefix = isLast ? '└─' : '├─';
|
||||
const color = item.color || 'blue';
|
||||
|
||||
return (
|
||||
<div key={item.label} className="flex items-center gap-2 py-1">
|
||||
{/* Tree prefix */}
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-500 font-mono text-xs w-5 flex-shrink-0">{prefix}</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-sm text-[color:var(--color-text)] dark:text-gray-300 flex-1 truncate">{item.label}</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-16 h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
...getProgressBarStyle(color),
|
||||
width: `${Math.min(100, barWidth)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white tabular-nums w-10 text-right flex-shrink-0">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* Two-column layout: Planner | Writer */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
{/* Planner Column */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wide mb-2" style={{ color: 'var(--color-primary)' }}>
|
||||
Planner
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{widget.plannerItems.map((item, idx) =>
|
||||
renderItem(item, idx === widget.plannerItems.length - 1)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Writer Column */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wide mb-2" style={{ color: 'var(--color-success)' }}>
|
||||
Writer
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{widget.writerItems.map((item, idx) =>
|
||||
renderItem(item, idx === widget.writerItems.length - 1)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Stats - Credits Used & Operations */}
|
||||
{(widget.creditsUsed !== undefined || widget.operationsCount !== undefined) && (
|
||||
<div className="flex items-center gap-4 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800 text-sm">
|
||||
{widget.creditsUsed !== undefined && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Credits Used: <strong className="text-[color:var(--color-text)] dark:text-white font-bold">{widget.creditsUsed.toLocaleString()}</strong>
|
||||
</span>
|
||||
)}
|
||||
{widget.creditsUsed !== undefined && widget.operationsCount !== undefined && (
|
||||
<span className="text-[color:var(--color-stroke)] dark:text-gray-600">│</span>
|
||||
)}
|
||||
{widget.operationsCount !== undefined && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Operations: <strong className="text-[color:var(--color-text)] dark:text-white font-bold">{widget.operationsCount}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Link */}
|
||||
{widget.analyticsHref && (
|
||||
<div className="pt-3 mt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
<Link
|
||||
to={widget.analyticsHref}
|
||||
className="text-sm font-medium hover:underline flex items-center gap-1"
|
||||
style={{ color: 'var(--color-primary)' }}
|
||||
>
|
||||
View Full Analytics
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
164
frontend/src/components/dashboard/NeedsAttentionBar.tsx
Normal file
164
frontend/src/components/dashboard/NeedsAttentionBar.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* NeedsAttentionBar - Compact alert bar for items needing user attention
|
||||
*
|
||||
* Shows at the top of dashboard when there are:
|
||||
* - Content pending review
|
||||
* - WordPress sync failures
|
||||
* - Incomplete site setup
|
||||
* - Automation failures
|
||||
*
|
||||
* Collapsible and only visible when there are items to show.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertIcon, ArrowRightIcon, ChevronDownIcon, RefreshIcon, CloseIcon } from '../../icons';
|
||||
|
||||
export type AttentionType = 'pending_review' | 'sync_failed' | 'setup_incomplete' | 'automation_failed' | 'credits_low';
|
||||
|
||||
export interface AttentionItem {
|
||||
id: string;
|
||||
type: AttentionType;
|
||||
title: string;
|
||||
count?: number;
|
||||
actionLabel: string;
|
||||
actionUrl?: string;
|
||||
onAction?: () => void;
|
||||
onRetry?: () => void;
|
||||
severity: 'warning' | 'error' | 'info';
|
||||
}
|
||||
|
||||
interface NeedsAttentionBarProps {
|
||||
items: AttentionItem[];
|
||||
onDismiss?: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const severityStyles = {
|
||||
warning: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-500/10',
|
||||
border: 'border-amber-200 dark:border-amber-500/30',
|
||||
icon: 'text-amber-500',
|
||||
text: 'text-amber-800 dark:text-amber-200',
|
||||
button: 'bg-amber-100 hover:bg-amber-200 text-amber-700 dark:bg-amber-500/20 dark:hover:bg-amber-500/30 dark:text-amber-200',
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50 dark:bg-red-500/10',
|
||||
border: 'border-red-200 dark:border-red-500/30',
|
||||
icon: 'text-red-500',
|
||||
text: 'text-red-800 dark:text-red-200',
|
||||
button: 'bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-500/20 dark:hover:bg-red-500/30 dark:text-red-200',
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-500/10',
|
||||
border: 'border-blue-200 dark:border-blue-500/30',
|
||||
icon: 'text-blue-500',
|
||||
text: 'text-blue-800 dark:text-blue-200',
|
||||
button: 'bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-500/20 dark:hover:bg-blue-500/30 dark:text-blue-200',
|
||||
},
|
||||
};
|
||||
|
||||
export default function NeedsAttentionBar({ items, onDismiss, className = '' }: NeedsAttentionBarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// Group items by severity for display priority
|
||||
const errorItems = items.filter(i => i.severity === 'error');
|
||||
const warningItems = items.filter(i => i.severity === 'warning');
|
||||
const infoItems = items.filter(i => i.severity === 'info');
|
||||
const sortedItems = [...errorItems, ...warningItems, ...infoItems];
|
||||
|
||||
const totalCount = items.reduce((sum, item) => sum + (item.count || 1), 0);
|
||||
|
||||
return (
|
||||
<div className={`mb-6 ${className}`}>
|
||||
{/* Header bar - always visible */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="w-full flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg hover:bg-amber-100 dark:hover:bg-amber-500/15 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertIcon className="w-5 h-5 text-amber-500" />
|
||||
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{totalCount} item{totalCount !== 1 ? 's' : ''} need{totalCount === 1 ? 's' : ''} attention
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`w-5 h-5 text-amber-500 transition-transform ${isCollapsed ? '' : 'rotate-180'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expandable content */}
|
||||
{!isCollapsed && (
|
||||
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{sortedItems.map((item) => {
|
||||
const styles = severityStyles[item.severity];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${styles.bg} ${styles.border}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<AlertIcon className={`w-4 h-4 flex-shrink-0 ${styles.icon}`} />
|
||||
<span className={`text-sm font-medium truncate ${styles.text}`}>
|
||||
{item.count ? `${item.count} ` : ''}{item.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
{item.onRetry && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onRetry?.();
|
||||
}}
|
||||
className={`p-1.5 rounded ${styles.button} transition-colors`}
|
||||
title="Retry"
|
||||
>
|
||||
<RefreshIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{item.actionUrl ? (
|
||||
<Link
|
||||
to={item.actionUrl}
|
||||
className={`px-2 py-1 text-xs font-medium rounded ${styles.button} transition-colors flex items-center gap-1`}
|
||||
>
|
||||
{item.actionLabel}
|
||||
<ArrowRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
) : item.onAction ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onAction?.();
|
||||
}}
|
||||
className={`px-2 py-1 text-xs font-medium rounded ${styles.button} transition-colors`}
|
||||
>
|
||||
{item.actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(item.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title="Dismiss"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/dashboard/index.ts
Normal file
50
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Dashboard Components - Centralized exports
|
||||
*
|
||||
* Usage:
|
||||
* import { CompactDashboard, ThreeWidgetFooter } from '../components/dashboard';
|
||||
*/
|
||||
|
||||
// Main dashboard components
|
||||
export { default as CompactDashboard } from './CompactDashboard';
|
||||
export type {
|
||||
CompactDashboardProps,
|
||||
AttentionItem,
|
||||
WorkflowCounts,
|
||||
AIOperation,
|
||||
RecentActivityItem,
|
||||
} from './CompactDashboard';
|
||||
|
||||
// Attention bar
|
||||
export { default as NeedsAttentionBar } from './NeedsAttentionBar';
|
||||
export type {
|
||||
AttentionItem as NeedsAttentionItem,
|
||||
AttentionType,
|
||||
} from './NeedsAttentionBar';
|
||||
|
||||
// Footer components
|
||||
export { default as ThreeWidgetFooter } from './ThreeWidgetFooter';
|
||||
export type {
|
||||
ThreeWidgetFooterProps,
|
||||
PageMetricItem,
|
||||
PageProgressWidget,
|
||||
PipelineStep,
|
||||
ModuleStatsWidget,
|
||||
CompletionItem,
|
||||
CompletionWidget,
|
||||
} from './ThreeWidgetFooter';
|
||||
|
||||
// Other dashboard components
|
||||
export { default as CreditBalanceWidget } from './CreditBalanceWidget';
|
||||
export { default as EnhancedMetricCard } from './EnhancedMetricCard';
|
||||
export { default as ModuleMetricsFooter } from './ModuleMetricsFooter';
|
||||
export type {
|
||||
SubmoduleColor,
|
||||
PageProgressWidget as ModulePageProgressWidget,
|
||||
ModulePipelineRow,
|
||||
ModuleStatsWidget as ModuleModuleStatsWidget,
|
||||
CompletionItem as ModuleCompletionItem,
|
||||
CompletionWidget as ModuleCompletionWidget,
|
||||
} from './ModuleMetricsFooter';
|
||||
export { default as UsageChartWidget } from './UsageChartWidget';
|
||||
export { default as WorkflowPipeline } from './WorkflowPipeline';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { ThemeToggleButton } from "../common/ThemeToggleButton";
|
||||
import NotificationDropdown from "./NotificationDropdown";
|
||||
import NotificationDropdown from "./NotificationDropdownNew";
|
||||
import UserDropdown from "./UserDropdown";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
|
||||
@@ -277,21 +277,21 @@ export function createApprovedPageConfig(params: {
|
||||
label: 'Approved',
|
||||
accentColor: 'green',
|
||||
calculate: (data: { totalCount: number }) => data.totalCount,
|
||||
tooltip: 'Total approved content ready for publishing.',
|
||||
tooltip: 'Articles approved and ready for publishing. Select and click "Sync to WordPress" to go live.',
|
||||
},
|
||||
{
|
||||
label: 'On Site',
|
||||
accentColor: 'blue',
|
||||
calculate: (data: { content: Content[] }) =>
|
||||
data.content.filter(c => c.external_id).length,
|
||||
tooltip: 'Content published to your website.',
|
||||
tooltip: 'Live articles published to your WordPress site. These are actively generating traffic.',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
accentColor: 'amber',
|
||||
calculate: (data: { content: Content[] }) =>
|
||||
data.content.filter(c => !c.external_id).length,
|
||||
tooltip: 'Approved content not yet published to site.',
|
||||
tooltip: 'Approved but not synced. Select and click "Sync to WordPress" to publish.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -456,30 +456,29 @@ export const createClustersPageConfig = (
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Topic clusters organizing your keywords. Each cluster should have 3-7 related keywords.',
|
||||
tooltip: 'Topic clusters grouping related keywords. Select clusters and click "Generate Ideas" to create content outlines.',
|
||||
},
|
||||
{
|
||||
label: 'New',
|
||||
value: 0,
|
||||
accentColor: 'amber' as const,
|
||||
calculate: (data) => data.clusters.filter((c: Cluster) => (c.ideas_count || 0) === 0).length,
|
||||
tooltip: 'Clusters without content ideas yet. Generate ideas for these clusters to move them into the pipeline.',
|
||||
tooltip: 'Clusters ready for idea generation. Select them and click "Generate Ideas" to create content outlines.',
|
||||
},
|
||||
{
|
||||
label: 'Keywords',
|
||||
value: 0,
|
||||
accentColor: 'purple' as const,
|
||||
calculate: (data) => data.clusters.reduce((sum: number, c: Cluster) => sum + (c.keywords_count || 0), 0),
|
||||
tooltip: 'Total keywords organized across all clusters. More keywords = better topic coverage.',
|
||||
tooltip: 'Keywords organized across clusters. Well-balanced clusters have 3-7 keywords each.',
|
||||
},
|
||||
{
|
||||
label: 'Volume',
|
||||
value: 0,
|
||||
accentColor: 'green' as const,
|
||||
calculate: (data) => data.clusters.reduce((sum: number, c: Cluster) => sum + (c.volume || 0), 0),
|
||||
tooltip: 'Combined search volume across all clusters. Prioritize high-volume clusters for maximum traffic.',
|
||||
tooltip: 'Combined monthly searches. Prioritize high-volume clusters for maximum traffic potential.',
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -458,30 +458,29 @@ export const createContentPageConfig = (
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total content pieces generated. Includes drafts, review, and published content.',
|
||||
tooltip: 'Total articles in your library. Add images and review before sending to the approval queue.',
|
||||
},
|
||||
{
|
||||
label: 'Draft',
|
||||
value: 0,
|
||||
accentColor: 'amber' as const,
|
||||
calculate: (data) => data.content.filter((c: Content) => c.status === 'draft').length,
|
||||
tooltip: 'Content in draft stage. Edit and refine before moving to review.',
|
||||
tooltip: 'Drafts needing images and review. Select and click "Generate Images" to add visuals.',
|
||||
},
|
||||
{
|
||||
label: 'In Review',
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.content.filter((c: Content) => c.status === 'review').length,
|
||||
tooltip: 'Content awaiting review and approval. Review for quality before publishing.',
|
||||
tooltip: 'Articles awaiting approval. Review for quality then click "Approve" to publish.',
|
||||
},
|
||||
{
|
||||
label: 'Published',
|
||||
value: 0,
|
||||
accentColor: 'green' as const,
|
||||
calculate: (data) => data.content.filter((c: Content) => c.status === 'published').length,
|
||||
tooltip: 'Published content ready for WordPress sync. Track your published library.',
|
||||
tooltip: 'Live articles published to your site. View in Writer → Published.',
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -405,30 +405,29 @@ export const createIdeasPageConfig = (
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total content ideas generated. Ideas become tasks in the content queue for writing.',
|
||||
tooltip: 'Content ideas generated. Review each idea\'s outline, then click "Create Task" to begin content generation.',
|
||||
},
|
||||
{
|
||||
label: 'New',
|
||||
value: 0,
|
||||
accentColor: 'amber' as const,
|
||||
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'new').length,
|
||||
tooltip: 'New ideas waiting for review. Approve ideas to queue them for content creation.',
|
||||
tooltip: 'Ideas not yet converted to tasks. Select and click "Create Tasks" to start the content writing process.',
|
||||
},
|
||||
{
|
||||
label: 'Queued',
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'queued').length,
|
||||
tooltip: 'Ideas queued for content generation. These will be converted to writing tasks automatically.',
|
||||
tooltip: 'Ideas ready for content generation. View their progress in Writer → Tasks queue.',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: 0,
|
||||
accentColor: 'green' as const,
|
||||
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'completed').length,
|
||||
tooltip: 'Ideas that have been successfully turned into content. Track your content creation progress.',
|
||||
tooltip: 'Ideas successfully turned into articles. Review completed content in Writer → Content.',
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -221,28 +221,28 @@ export const createImagesPageConfig = (
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total content pieces with image generation. Track image coverage across all content.',
|
||||
tooltip: 'Articles in your library. Each can have 1 featured image + multiple in-article images.',
|
||||
},
|
||||
{
|
||||
label: 'Complete',
|
||||
value: 0,
|
||||
accentColor: 'green' as const,
|
||||
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'complete').length,
|
||||
tooltip: 'Content with all images generated. Ready for publishing with full visual coverage.',
|
||||
tooltip: 'Articles with all images generated. Ready for publishing with full visual coverage.',
|
||||
},
|
||||
{
|
||||
label: 'Partial',
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'partial').length,
|
||||
tooltip: 'Content with some images missing. Generate remaining images to complete visual assets.',
|
||||
tooltip: 'Articles with some images missing. Select and click "Generate Images" to complete visuals.',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: 0,
|
||||
accentColor: 'amber' as const,
|
||||
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'pending').length,
|
||||
tooltip: 'Content waiting for image generation. Queue these to start creating visual assets.',
|
||||
tooltip: 'Articles needing images. Select and click "Generate Prompts" then "Generate Images".',
|
||||
},
|
||||
],
|
||||
maxInArticleImages: maxImages,
|
||||
|
||||
@@ -435,28 +435,28 @@ export const createKeywordsPageConfig = (
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total keywords added to your workflow. Minimum 5 keywords are needed for clustering.',
|
||||
tooltip: 'Keywords ready for clustering. Select unclustered keywords and click "Auto Cluster" to organize them into topic groups.',
|
||||
},
|
||||
{
|
||||
label: 'Clustered',
|
||||
value: 0,
|
||||
accentColor: 'green' as const,
|
||||
calculate: (data) => data.keywords.filter((k: Keyword) => k.cluster_id).length,
|
||||
tooltip: 'Keywords grouped into topical clusters. Clustered keywords are ready for content ideation.',
|
||||
tooltip: 'Clusters with 3-7 keywords are optimal for content creation. Click on a cluster to generate content ideas from it.',
|
||||
},
|
||||
{
|
||||
label: 'Unmapped',
|
||||
value: 0,
|
||||
accentColor: 'amber' as const,
|
||||
calculate: (data) => data.keywords.filter((k: Keyword) => !k.cluster_id).length,
|
||||
tooltip: 'Unclustered keywords waiting to be organized. Select keywords and use Auto-Cluster to group them.',
|
||||
tooltip: 'Keywords waiting to be clustered. Select them and click "Auto Cluster" to organize into topic groups.',
|
||||
},
|
||||
{
|
||||
label: 'Volume',
|
||||
value: 0,
|
||||
accentColor: 'purple' as const,
|
||||
calculate: (data) => data.keywords.reduce((sum: number, k: Keyword) => sum + (k.volume || 0), 0),
|
||||
tooltip: 'Total monthly search volume across all keywords. Higher volume = more traffic potential.',
|
||||
tooltip: 'Combined monthly searches. Prioritize higher-volume keywords when creating content.',
|
||||
},
|
||||
],
|
||||
// bulkActions and rowActions are now global - defined in table-actions.config.ts
|
||||
|
||||
@@ -265,25 +265,25 @@ export function createReviewPageConfig(params: {
|
||||
label: 'Ready',
|
||||
accentColor: 'blue',
|
||||
calculate: ({ totalCount }) => totalCount,
|
||||
tooltip: 'Content ready for final review. Review quality, SEO, and images before publishing.',
|
||||
tooltip: 'Articles awaiting final review. Check quality and SEO before clicking "Approve & Publish".',
|
||||
},
|
||||
{
|
||||
label: 'Images',
|
||||
accentColor: 'green',
|
||||
calculate: ({ content }) => content.filter(c => c.has_generated_images).length,
|
||||
tooltip: 'Content with generated images. Visual assets complete and ready for review.',
|
||||
tooltip: 'Articles with complete visuals. Articles with images get 94% more engagement.',
|
||||
},
|
||||
{
|
||||
label: 'Optimized',
|
||||
accentColor: 'purple',
|
||||
calculate: ({ content }) => content.filter(c => c.optimization_scores && c.optimization_scores.overall_score >= 80).length,
|
||||
tooltip: 'Content with high SEO optimization scores (80%+). Well-optimized for search engines.',
|
||||
tooltip: 'High SEO scores (80%+). These articles are well-optimized for search rankings.',
|
||||
},
|
||||
{
|
||||
label: 'Sync Ready',
|
||||
accentColor: 'amber',
|
||||
calculate: ({ content }) => content.filter(c => c.has_generated_images && c.optimization_scores && c.optimization_scores.overall_score >= 70).length,
|
||||
tooltip: 'Content ready for WordPress sync. Has images and good optimization score.',
|
||||
tooltip: 'Ready to publish! Has images + good SEO. Select and click "Publish to WordPress".',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -460,37 +460,36 @@ export const createTasksPageConfig = (
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.totalCount || 0,
|
||||
tooltip: 'Total content generation tasks. Tasks process ideas into written content automatically.',
|
||||
tooltip: 'Total content generation tasks. Select tasks and click "Generate Content" to write articles.',
|
||||
},
|
||||
{
|
||||
label: 'In Queue',
|
||||
value: 0,
|
||||
accentColor: 'amber' as const,
|
||||
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'queued').length,
|
||||
tooltip: 'Tasks queued for processing. These will be picked up by the content generation system.',
|
||||
tooltip: 'Tasks waiting for content generation. Select and click "Generate Content" to write articles.',
|
||||
},
|
||||
{
|
||||
label: 'Processing',
|
||||
value: 0,
|
||||
accentColor: 'blue' as const,
|
||||
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'in_progress').length,
|
||||
tooltip: 'Tasks currently being processed. Content is being generated by AI right now.',
|
||||
tooltip: 'Tasks being written by AI. Content will appear in Drafts when complete (~2-3 min each).',
|
||||
},
|
||||
{
|
||||
label: 'Completed',
|
||||
value: 0,
|
||||
accentColor: 'green' as const,
|
||||
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'completed').length,
|
||||
tooltip: 'Successfully completed tasks. Generated content is ready for review and publishing.',
|
||||
tooltip: 'Tasks with generated content. Review articles in Writer → Content before publishing.',
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
value: 0,
|
||||
accentColor: 'red' as const,
|
||||
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'failed').length,
|
||||
tooltip: 'Failed tasks that need attention. Review error logs and retry or modify the task.',
|
||||
tooltip: 'Failed tasks needing attention. Click to view error details and retry generation.',
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { fetchAPI } from '../services/api';
|
||||
import { useNotificationStore } from '../store/notificationStore';
|
||||
|
||||
export interface ProgressState {
|
||||
percentage: number;
|
||||
@@ -57,6 +58,9 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// Notification store for AI task notifications
|
||||
const addNotification = useNotificationStore((state) => state.addAITaskNotification);
|
||||
|
||||
// Step logs state for debugging
|
||||
const [stepLogs, setStepLogs] = useState<Array<{
|
||||
stepNumber: number;
|
||||
@@ -581,6 +585,9 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
setStepLogs(allSteps);
|
||||
}
|
||||
|
||||
// Add success notification
|
||||
addNotification(title, stepInfo.friendlyMessage, true);
|
||||
|
||||
// Stop polling on SUCCESS
|
||||
isStopped = true;
|
||||
if (intervalId) {
|
||||
@@ -637,6 +644,9 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
setStepLogs(allSteps);
|
||||
}
|
||||
|
||||
// Add failure notification
|
||||
addNotification(title, errorMsg, false);
|
||||
|
||||
// Stop polling on FAILURE
|
||||
isStopped = true;
|
||||
if (intervalId) {
|
||||
|
||||
388
frontend/src/hooks/useThreeWidgetFooter.ts
Normal file
388
frontend/src/hooks/useThreeWidgetFooter.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* useThreeWidgetFooter - Hook to build ThreeWidgetFooter props
|
||||
*
|
||||
* Provides helper functions to construct the three widgets:
|
||||
* - Page Progress (current page metrics)
|
||||
* - Module Stats (workflow pipeline)
|
||||
* - Completion Stats (both modules summary)
|
||||
*
|
||||
* Usage:
|
||||
* const footerProps = useThreeWidgetFooter({
|
||||
* module: 'planner',
|
||||
* currentPage: 'keywords',
|
||||
* pageData: { keywords: [...], clusters: [...] },
|
||||
* pipelineData: { ... }
|
||||
* });
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
ThreeWidgetFooterProps,
|
||||
PageProgressWidget,
|
||||
ModuleStatsWidget,
|
||||
CompletionWidget,
|
||||
} from '../components/dashboard/ThreeWidgetFooter';
|
||||
|
||||
// ============================================================================
|
||||
// PLANNER MODULE CONFIGURATIONS
|
||||
// ============================================================================
|
||||
|
||||
interface PlannerPageData {
|
||||
keywords?: Array<{ cluster_id?: number | null; volume?: number }>;
|
||||
clusters?: Array<{ ideas_count?: number; keywords_count?: number }>;
|
||||
ideas?: Array<{ status?: string }>;
|
||||
totalKeywords?: number;
|
||||
totalClusters?: number;
|
||||
totalIdeas?: number;
|
||||
}
|
||||
|
||||
interface WriterPageData {
|
||||
tasks?: Array<{ status?: string }>;
|
||||
content?: Array<{ status?: string; has_generated_images?: boolean }>;
|
||||
totalTasks?: number;
|
||||
totalContent?: number;
|
||||
totalPublished?: number;
|
||||
}
|
||||
|
||||
interface CompletionData {
|
||||
keywordsClustered?: number;
|
||||
clustersCreated?: number;
|
||||
ideasGenerated?: number;
|
||||
contentGenerated?: number;
|
||||
imagesCreated?: number;
|
||||
articlesPublished?: number;
|
||||
creditsUsed?: number;
|
||||
totalOperations?: number;
|
||||
}
|
||||
|
||||
interface UseThreeWidgetFooterOptions {
|
||||
module: 'planner' | 'writer';
|
||||
currentPage: 'keywords' | 'clusters' | 'ideas' | 'tasks' | 'content' | 'images' | 'review' | 'published';
|
||||
plannerData?: PlannerPageData;
|
||||
writerData?: WriterPageData;
|
||||
completionData?: CompletionData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLANNER PAGE PROGRESS BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
function buildKeywordsPageProgress(data: PlannerPageData): PageProgressWidget {
|
||||
const keywords = data.keywords || [];
|
||||
const totalKeywords = data.totalKeywords || keywords.length;
|
||||
const clusteredCount = keywords.filter(k => k.cluster_id).length;
|
||||
const unmappedCount = keywords.filter(k => !k.cluster_id).length;
|
||||
const totalVolume = keywords.reduce((sum, k) => sum + (k.volume || 0), 0);
|
||||
const clusteredPercent = totalKeywords > 0 ? Math.round((clusteredCount / totalKeywords) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Keywords', value: totalKeywords },
|
||||
{ label: 'Clustered', value: clusteredCount, suffix: ` (${clusteredPercent}%)` },
|
||||
{ label: 'Unmapped', value: unmappedCount },
|
||||
{ label: 'Volume', value: totalVolume >= 1000 ? `${(totalVolume / 1000).toFixed(1)}K` : totalVolume },
|
||||
],
|
||||
progress: {
|
||||
value: clusteredPercent,
|
||||
label: `${clusteredPercent}% Clustered`,
|
||||
color: clusteredPercent >= 80 ? 'success' : 'primary',
|
||||
},
|
||||
hint: unmappedCount > 0 ? `${unmappedCount} keywords ready to cluster` : 'All keywords clustered!',
|
||||
};
|
||||
}
|
||||
|
||||
function buildClustersPageProgress(data: PlannerPageData): PageProgressWidget {
|
||||
const clusters = data.clusters || [];
|
||||
const totalClusters = data.totalClusters || clusters.length;
|
||||
const withIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
|
||||
const totalKeywords = clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0);
|
||||
const readyClusters = clusters.filter(c => (c.ideas_count || 0) === 0).length;
|
||||
const ideasPercent = totalClusters > 0 ? Math.round((withIdeas / totalClusters) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Clusters', value: totalClusters },
|
||||
{ label: 'With Ideas', value: withIdeas, suffix: ` (${ideasPercent}%)` },
|
||||
{ label: 'Keywords', value: totalKeywords },
|
||||
{ label: 'Ready', value: readyClusters },
|
||||
],
|
||||
progress: {
|
||||
value: ideasPercent,
|
||||
label: `${ideasPercent}% Have Ideas`,
|
||||
color: ideasPercent >= 70 ? 'success' : 'primary',
|
||||
},
|
||||
hint: readyClusters > 0 ? `${readyClusters} clusters ready for idea generation` : 'All clusters have ideas!',
|
||||
};
|
||||
}
|
||||
|
||||
function buildIdeasPageProgress(data: PlannerPageData): PageProgressWidget {
|
||||
const ideas = data.ideas || [];
|
||||
const totalIdeas = data.totalIdeas || ideas.length;
|
||||
const inTasks = ideas.filter(i => i.status === 'completed' || i.status === 'queued').length;
|
||||
const pending = ideas.filter(i => i.status === 'new').length;
|
||||
const convertedPercent = totalIdeas > 0 ? Math.round((inTasks / totalIdeas) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Ideas', value: totalIdeas },
|
||||
{ label: 'In Tasks', value: inTasks, suffix: ` (${convertedPercent}%)` },
|
||||
{ label: 'Pending', value: pending },
|
||||
{ label: 'From Clusters', value: data.totalClusters || 0 },
|
||||
],
|
||||
progress: {
|
||||
value: convertedPercent,
|
||||
label: `${convertedPercent}% Converted`,
|
||||
color: convertedPercent >= 60 ? 'success' : 'primary',
|
||||
},
|
||||
hint: pending > 0 ? `${pending} ideas ready to become tasks` : 'All ideas converted!',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WRITER PAGE PROGRESS BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
function buildTasksPageProgress(data: WriterPageData): PageProgressWidget {
|
||||
const tasks = data.tasks || [];
|
||||
const total = data.totalTasks || tasks.length;
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const queue = tasks.filter(t => t.status === 'queued').length;
|
||||
const processing = tasks.filter(t => t.status === 'in_progress').length;
|
||||
const completedPercent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Total', value: total },
|
||||
{ label: 'Complete', value: completed, suffix: ` (${completedPercent}%)` },
|
||||
{ label: 'Queue', value: queue },
|
||||
{ label: 'Processing', value: processing },
|
||||
],
|
||||
progress: {
|
||||
value: completedPercent,
|
||||
label: `${completedPercent}% Generated`,
|
||||
color: completedPercent >= 60 ? 'success' : 'primary',
|
||||
},
|
||||
hint: queue > 0 ? `${queue} tasks in queue for content generation` : 'All tasks processed!',
|
||||
};
|
||||
}
|
||||
|
||||
function buildContentPageProgress(data: WriterPageData): PageProgressWidget {
|
||||
const content = data.content || [];
|
||||
const drafts = content.filter(c => c.status === 'draft').length;
|
||||
const hasImages = content.filter(c => c.has_generated_images).length;
|
||||
const ready = content.filter(c => c.status === 'review' || c.status === 'published').length;
|
||||
const imagesPercent = drafts > 0 ? Math.round((hasImages / drafts) * 100) : 0;
|
||||
|
||||
return {
|
||||
title: 'Page Progress',
|
||||
metrics: [
|
||||
{ label: 'Drafts', value: drafts },
|
||||
{ label: 'Has Images', value: hasImages, suffix: ` (${imagesPercent}%)` },
|
||||
{ label: 'Total Words', value: '12.5K' }, // Would need word count from API
|
||||
{ label: 'Ready', value: ready },
|
||||
],
|
||||
progress: {
|
||||
value: imagesPercent,
|
||||
label: `${imagesPercent}% Have Images`,
|
||||
color: imagesPercent >= 70 ? 'success' : 'primary',
|
||||
},
|
||||
hint: drafts - hasImages > 0 ? `${drafts - hasImages} drafts need images before review` : 'All drafts have images!',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MODULE STATS BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
function buildPlannerModuleStats(data: PlannerPageData): ModuleStatsWidget {
|
||||
const keywords = data.keywords || [];
|
||||
const clusters = data.clusters || [];
|
||||
const ideas = data.ideas || [];
|
||||
|
||||
const totalKeywords = data.totalKeywords || keywords.length;
|
||||
const totalClusters = data.totalClusters || clusters.length;
|
||||
const totalIdeas = data.totalIdeas || ideas.length;
|
||||
const clusteredKeywords = keywords.filter(k => k.cluster_id).length;
|
||||
const clustersWithIdeas = clusters.filter(c => (c.ideas_count || 0) > 0).length;
|
||||
const ideasInTasks = ideas.filter(i => i.status === 'completed' || i.status === 'queued').length;
|
||||
|
||||
return {
|
||||
title: 'Planner Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: totalKeywords,
|
||||
toLabel: 'Clusters',
|
||||
toValue: totalClusters,
|
||||
actionLabel: 'Auto Cluster',
|
||||
progressValue: totalKeywords > 0 ? Math.round((clusteredKeywords / totalKeywords) * 100) : 0,
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: totalClusters,
|
||||
toLabel: 'Ideas',
|
||||
toValue: totalIdeas,
|
||||
actionLabel: 'Generate Ideas',
|
||||
progressValue: totalClusters > 0 ? Math.round((clustersWithIdeas / totalClusters) * 100) : 0,
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: totalIdeas,
|
||||
toLabel: 'Tasks',
|
||||
toValue: ideasInTasks,
|
||||
actionLabel: 'Create Tasks',
|
||||
progressValue: totalIdeas > 0 ? Math.round((ideasInTasks / totalIdeas) * 100) : 0,
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildWriterModuleStats(data: WriterPageData): ModuleStatsWidget {
|
||||
const tasks = data.tasks || [];
|
||||
const content = data.content || [];
|
||||
|
||||
const totalTasks = data.totalTasks || tasks.length;
|
||||
const completedTasks = tasks.filter(t => t.status === 'completed').length;
|
||||
const drafts = content.filter(c => c.status === 'draft').length;
|
||||
const withImages = content.filter(c => c.has_generated_images).length;
|
||||
const ready = content.filter(c => c.status === 'review').length;
|
||||
const published = data.totalPublished || content.filter(c => c.status === 'published').length;
|
||||
|
||||
return {
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: totalTasks,
|
||||
toLabel: 'Drafts',
|
||||
toValue: drafts,
|
||||
actionLabel: 'Generate Content',
|
||||
progressValue: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: drafts,
|
||||
toLabel: 'Images',
|
||||
toValue: withImages,
|
||||
actionLabel: 'Generate Images',
|
||||
progressValue: drafts > 0 ? Math.round((withImages / drafts) * 100) : 0,
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: ready,
|
||||
toLabel: 'Published',
|
||||
toValue: published,
|
||||
actionLabel: 'Review & Publish',
|
||||
progressValue: ready > 0 ? Math.round((published / (ready + published)) * 100) : 0,
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/published' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPLETION STATS BUILDER
|
||||
// ============================================================================
|
||||
|
||||
function buildCompletionStats(data: CompletionData): CompletionWidget {
|
||||
const maxValue = Math.max(
|
||||
data.keywordsClustered || 0,
|
||||
data.clustersCreated || 0,
|
||||
data.ideasGenerated || 0,
|
||||
data.contentGenerated || 0,
|
||||
data.imagesCreated || 0,
|
||||
data.articlesPublished || 0,
|
||||
1
|
||||
);
|
||||
|
||||
const calcBarWidth = (value: number) => Math.round((value / maxValue) * 100);
|
||||
|
||||
return {
|
||||
plannerStats: [
|
||||
{ label: 'Keywords Clustered', value: data.keywordsClustered || 0, barWidth: calcBarWidth(data.keywordsClustered || 0) },
|
||||
{ label: 'Clusters Created', value: data.clustersCreated || 0, barWidth: calcBarWidth(data.clustersCreated || 0) },
|
||||
{ label: 'Ideas Generated', value: data.ideasGenerated || 0, barWidth: calcBarWidth(data.ideasGenerated || 0) },
|
||||
],
|
||||
writerStats: [
|
||||
{ label: 'Content Generated', value: data.contentGenerated || 0, barWidth: calcBarWidth(data.contentGenerated || 0) },
|
||||
{ label: 'Images Created', value: data.imagesCreated || 0, barWidth: calcBarWidth(data.imagesCreated || 0) },
|
||||
{ label: 'Articles Published', value: data.articlesPublished || 0, barWidth: calcBarWidth(data.articlesPublished || 0) },
|
||||
],
|
||||
summary: {
|
||||
creditsUsed: data.creditsUsed || 0,
|
||||
operations: data.totalOperations || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN HOOK
|
||||
// ============================================================================
|
||||
|
||||
export function useThreeWidgetFooter(options: UseThreeWidgetFooterOptions): ThreeWidgetFooterProps {
|
||||
const { module, currentPage, plannerData = {}, writerData = {}, completionData = {} } = options;
|
||||
|
||||
return useMemo(() => {
|
||||
// Build page progress based on current page
|
||||
let pageProgress: PageProgressWidget;
|
||||
|
||||
if (module === 'planner') {
|
||||
switch (currentPage) {
|
||||
case 'keywords':
|
||||
pageProgress = buildKeywordsPageProgress(plannerData);
|
||||
break;
|
||||
case 'clusters':
|
||||
pageProgress = buildClustersPageProgress(plannerData);
|
||||
break;
|
||||
case 'ideas':
|
||||
pageProgress = buildIdeasPageProgress(plannerData);
|
||||
break;
|
||||
default:
|
||||
pageProgress = buildKeywordsPageProgress(plannerData);
|
||||
}
|
||||
} else {
|
||||
switch (currentPage) {
|
||||
case 'tasks':
|
||||
pageProgress = buildTasksPageProgress(writerData);
|
||||
break;
|
||||
case 'content':
|
||||
case 'images':
|
||||
case 'review':
|
||||
pageProgress = buildContentPageProgress(writerData);
|
||||
break;
|
||||
default:
|
||||
pageProgress = buildTasksPageProgress(writerData);
|
||||
}
|
||||
}
|
||||
|
||||
// Build module stats
|
||||
const moduleStats = module === 'planner'
|
||||
? buildPlannerModuleStats(plannerData)
|
||||
: buildWriterModuleStats(writerData);
|
||||
|
||||
// Build completion stats
|
||||
const completion = buildCompletionStats(completionData);
|
||||
|
||||
return {
|
||||
pageProgress,
|
||||
moduleStats,
|
||||
completion,
|
||||
};
|
||||
}, [module, currentPage, plannerData, writerData, completionData]);
|
||||
}
|
||||
|
||||
export default useThreeWidgetFooter;
|
||||
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { usePageContext } from "../context/PageContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
|
||||
import NotificationDropdown from "../components/header/NotificationDropdown";
|
||||
import NotificationDropdown from "../components/header/NotificationDropdownNew";
|
||||
import UserDropdown from "../components/header/UserDropdown";
|
||||
import { HeaderMetrics } from "../components/header/HeaderMetrics";
|
||||
import SearchModal from "../components/common/SearchModal";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState, lazy, Suspense, useRef } from "react";
|
||||
import React, { useEffect, useState, lazy, Suspense, useRef, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import CreditBalanceWidget from "../../components/dashboard/CreditBalanceWidget";
|
||||
import UsageChartWidget from "../../components/dashboard/UsageChartWidget";
|
||||
import EnhancedMetricCard from "../../components/dashboard/EnhancedMetricCard";
|
||||
import NeedsAttentionBar, { AttentionItem } from "../../components/dashboard/NeedsAttentionBar";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import PageHeader from "../../components/common/PageHeader";
|
||||
import WorkflowGuide from "../../components/onboarding/WorkflowGuide";
|
||||
@@ -38,7 +39,10 @@ import {
|
||||
fetchContent,
|
||||
fetchContentImages,
|
||||
fetchSites,
|
||||
fetchDashboardSummary,
|
||||
Site,
|
||||
DashboardSummary,
|
||||
DashboardActivity,
|
||||
} from "../../services/api";
|
||||
import { useSiteStore } from "../../store/siteStore";
|
||||
import { useSectorStore } from "../../store/sectorStore";
|
||||
@@ -432,39 +436,93 @@ export default function Home() {
|
||||
},
|
||||
];
|
||||
|
||||
// Dashboard summary state for API data (recent activity, etc.)
|
||||
const [dashboardData, setDashboardData] = useState<DashboardSummary | null>(null);
|
||||
|
||||
// Build attention items - prefer API data when available, fallback to computed
|
||||
const attentionItems = useMemo<AttentionItem[]>(() => {
|
||||
// If we have dashboard API data, convert it to our AttentionItem format
|
||||
if (dashboardData?.needs_attention && dashboardData.needs_attention.length > 0) {
|
||||
return dashboardData.needs_attention.map(item => ({
|
||||
id: item.id,
|
||||
type: item.type as AttentionItem['type'],
|
||||
title: item.title,
|
||||
count: item.count,
|
||||
actionLabel: item.action_label,
|
||||
actionUrl: item.action_url,
|
||||
severity: item.severity as AttentionItem['severity'],
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback: compute from local state
|
||||
const items: AttentionItem[] = [];
|
||||
|
||||
// Check for content pending review
|
||||
const reviewCount = progress.contentCount - progress.publishedCount;
|
||||
if (reviewCount > 0 && reviewCount < 20) {
|
||||
items.push({
|
||||
id: 'pending-review',
|
||||
type: 'pending_review',
|
||||
title: 'pending review',
|
||||
count: reviewCount,
|
||||
actionLabel: 'Review',
|
||||
actionUrl: '/writer/review',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for sites without setup (no keywords)
|
||||
const sitesWithoutSetup = sites.filter(s => !s.keywords_count || s.keywords_count === 0);
|
||||
if (sitesWithoutSetup.length > 0) {
|
||||
items.push({
|
||||
id: 'setup-incomplete',
|
||||
type: 'setup_incomplete',
|
||||
title: sitesWithoutSetup.length === 1
|
||||
? `${sitesWithoutSetup[0].name} needs setup`
|
||||
: `${sitesWithoutSetup.length} sites need setup`,
|
||||
actionLabel: 'Complete',
|
||||
actionUrl: sitesWithoutSetup.length === 1 ? `/sites/${sitesWithoutSetup[0].id}` : '/sites',
|
||||
severity: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for low credits (if balance is low)
|
||||
if (balance && balance.credits_remaining !== undefined) {
|
||||
const creditsPercent = (balance.credits_remaining / (balance.credits || 1)) * 100;
|
||||
if (creditsPercent < 20 && creditsPercent > 0) {
|
||||
items.push({
|
||||
id: 'credits-low',
|
||||
type: 'credits_low',
|
||||
title: `Credits running low (${balance.credits_remaining} remaining)`,
|
||||
actionLabel: 'Upgrade',
|
||||
actionUrl: '/billing/plans',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [dashboardData, progress, sites, balance]);
|
||||
|
||||
const fetchAppInsights = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
// Determine site_id based on filter
|
||||
const siteId = siteFilter === 'all' ? undefined : siteFilter;
|
||||
|
||||
// Fetch sequentially with small delays to avoid burst throttling
|
||||
const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId });
|
||||
await delay(120);
|
||||
const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId });
|
||||
await delay(120);
|
||||
const ideasRes = await fetchContentIdeas({ page_size: 1, site_id: siteId });
|
||||
await delay(120);
|
||||
const tasksRes = await fetchTasks({ page_size: 1, site_id: siteId });
|
||||
await delay(120);
|
||||
const contentRes = await fetchContent({ page_size: 1, site_id: siteId });
|
||||
await delay(120);
|
||||
const imagesRes = await fetchContentImages({ page_size: 1, site_id: siteId });
|
||||
// Use aggregated dashboard API - single call replaces 6 sequential calls
|
||||
const summary = await fetchDashboardSummary({ site_id: siteId, days: 7 });
|
||||
setDashboardData(summary);
|
||||
|
||||
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;
|
||||
|
||||
// Check for published content (status = 'published')
|
||||
const publishedContent = totalContent; // TODO: Filter by published status when API supports it
|
||||
const workflowCompletionRate = totalKeywords > 0
|
||||
? Math.round((publishedContent / totalKeywords) * 100)
|
||||
: 0;
|
||||
const totalKeywords = summary.pipeline.keywords;
|
||||
const totalClusters = summary.pipeline.clusters;
|
||||
const totalIdeas = summary.pipeline.ideas;
|
||||
const totalTasks = summary.pipeline.tasks;
|
||||
const totalContent = summary.pipeline.total_content;
|
||||
const totalImages = 0; // Images count not in pipeline - fetch separately if needed
|
||||
const publishedContent = summary.pipeline.published;
|
||||
const workflowCompletionRate = summary.pipeline.completion_percentage;
|
||||
|
||||
// Check if site has industry and sectors (site with sectors means industry is set)
|
||||
const hasSiteWithSectors = sites.some(site => site.active_sectors_count > 0);
|
||||
@@ -478,9 +536,9 @@ export default function Home() {
|
||||
totalImages,
|
||||
publishedContent,
|
||||
workflowCompletionRate,
|
||||
contentThisWeek: Math.floor(totalContent * 0.3),
|
||||
contentThisMonth: Math.floor(totalContent * 0.7),
|
||||
automationEnabled: false,
|
||||
contentThisWeek: summary.content_velocity.this_week,
|
||||
contentThisMonth: summary.content_velocity.this_month,
|
||||
automationEnabled: summary.automation.enabled,
|
||||
});
|
||||
|
||||
// Update progress
|
||||
@@ -591,6 +649,9 @@ export default function Home() {
|
||||
title="Dashboard - IGNY8"
|
||||
description="IGNY8 AI-Powered Content Creation Dashboard"
|
||||
/>
|
||||
|
||||
{/* Needs Attention Bar - Shows items requiring user action */}
|
||||
<NeedsAttentionBar items={attentionItems} />
|
||||
|
||||
{/* Custom Header with Site Selector and Refresh */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
|
||||
@@ -27,7 +27,11 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ModuleMetricsFooter, {
|
||||
PageProgressWidget,
|
||||
ModuleStatsWidget,
|
||||
CompletionWidget
|
||||
} from '../../components/dashboard/ModuleMetricsFooter';
|
||||
|
||||
export default function Clusters() {
|
||||
const toast = useToast();
|
||||
@@ -486,37 +490,88 @@ export default function Clusters() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
{/* Module Metrics Footer - 3-Widget Layout */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Keywords',
|
||||
value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0).toLocaleString(),
|
||||
subtitle: `in ${totalCount} clusters`,
|
||||
icon: <ListIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/planner/keywords',
|
||||
submoduleColor="green"
|
||||
threeWidgetLayout={{
|
||||
// Widget 1: Page Progress (Clusters)
|
||||
pageProgress: {
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'green',
|
||||
metrics: [
|
||||
{ label: 'Clusters', value: totalCount },
|
||||
{ label: 'With Ideas', value: clusters.filter(c => (c.ideas_count || 0) > 0).length, percentage: `${totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Keywords', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0) },
|
||||
{ label: 'Ready', value: clusters.filter(c => (c.ideas_count || 0) === 0).length },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
|
||||
label: 'Have Ideas',
|
||||
color: 'green',
|
||||
},
|
||||
hint: clusters.filter(c => (c.ideas_count || 0) === 0).length > 0
|
||||
? `${clusters.filter(c => (c.ideas_count || 0) === 0).length} clusters ready for idea generation`
|
||||
: 'All clusters have ideas!',
|
||||
},
|
||||
{
|
||||
title: 'Content Ideas',
|
||||
value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0).toLocaleString(),
|
||||
subtitle: `across ${clusters.filter(c => (c.ideas_count || 0) > 0).length} clusters`,
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/planner/ideas',
|
||||
// Widget 2: Module Stats (Planner Pipeline)
|
||||
moduleStats: {
|
||||
title: 'Planner Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0),
|
||||
fromHref: '/planner/keywords',
|
||||
actionLabel: 'Auto Cluster',
|
||||
toLabel: 'Clusters',
|
||||
toValue: totalCount,
|
||||
progress: 100,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Generate Ideas',
|
||||
toLabel: 'Ideas',
|
||||
toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
|
||||
toHref: '/planner/ideas',
|
||||
progress: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
|
||||
fromHref: '/planner/ideas',
|
||||
actionLabel: 'Create Tasks',
|
||||
toLabel: 'Tasks',
|
||||
toValue: 0,
|
||||
toHref: '/writer/tasks',
|
||||
progress: 0,
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Ready to Write',
|
||||
value: clusters.filter(c => (c.ideas_count || 0) > 0 && c.status === 'active').length.toLocaleString(),
|
||||
subtitle: 'clusters with approved ideas',
|
||||
icon: <GroupIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
// Widget 3: Completion Stats
|
||||
completion: {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords', value: clusters.reduce((sum, c) => sum + (c.keywords_count || 0), 0), color: 'blue' },
|
||||
{ label: 'Clusters', value: totalCount, color: 'green' },
|
||||
{ label: 'Ideas', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content', value: 0, color: 'blue' },
|
||||
{ label: 'Images', value: 0, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/analytics',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Idea Generation Pipeline: Clusters with content ideas generated (ready for downstream content creation)',
|
||||
value: totalCount > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / totalCount) * 100) : 0,
|
||||
color: 'purple',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ import { createIdeasPageConfig } from '../../config/pages/ideas.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ModuleMetricsFooter, {
|
||||
PageProgressWidget,
|
||||
ModuleStatsWidget,
|
||||
CompletionWidget
|
||||
} from '../../components/dashboard/ModuleMetricsFooter';
|
||||
|
||||
export default function Ideas() {
|
||||
const toast = useToast();
|
||||
@@ -414,45 +418,88 @@ export default function Ideas() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
{/* Module Metrics Footer - 3-Widget Layout */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Clusters',
|
||||
value: clusters.length.toLocaleString(),
|
||||
subtitle: 'keyword groups',
|
||||
icon: <GroupIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
href: '/planner/clusters',
|
||||
submoduleColor="amber"
|
||||
threeWidgetLayout={{
|
||||
// Widget 1: Page Progress (Ideas)
|
||||
pageProgress: {
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'amber',
|
||||
metrics: [
|
||||
{ label: 'Ideas', value: totalCount },
|
||||
{ label: 'In Tasks', value: ideas.filter(i => i.status === 'queued' || i.status === 'completed').length, percentage: `${totalCount > 0 ? Math.round((ideas.filter(i => i.status !== 'new').length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Pending', value: ideas.filter(i => i.status === 'new').length },
|
||||
{ label: 'Clusters', value: clusters.length },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((ideas.filter(i => i.status !== 'new').length / totalCount) * 100) : 0,
|
||||
label: 'Converted',
|
||||
color: 'amber',
|
||||
},
|
||||
hint: ideas.filter(i => i.status === 'new').length > 0
|
||||
? `${ideas.filter(i => i.status === 'new').length} ideas ready to become tasks`
|
||||
: 'All ideas converted to tasks!',
|
||||
},
|
||||
{
|
||||
title: 'Ready to Queue',
|
||||
value: ideas.filter(i => i.status === 'new').length.toLocaleString(),
|
||||
subtitle: 'awaiting approval',
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'orange',
|
||||
// Widget 2: Module Stats (Planner Pipeline)
|
||||
moduleStats: {
|
||||
title: 'Planner Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: 0,
|
||||
fromHref: '/planner/keywords',
|
||||
actionLabel: 'Auto Cluster',
|
||||
toLabel: 'Clusters',
|
||||
toValue: clusters.length,
|
||||
toHref: '/planner/clusters',
|
||||
progress: 100,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: clusters.length,
|
||||
fromHref: '/planner/clusters',
|
||||
actionLabel: 'Generate Ideas',
|
||||
toLabel: 'Ideas',
|
||||
toValue: totalCount,
|
||||
progress: 100,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Create Tasks',
|
||||
toLabel: 'Tasks',
|
||||
toValue: ideas.filter(i => i.status === 'queued' || i.status === 'completed').length,
|
||||
toHref: '/writer/tasks',
|
||||
progress: totalCount > 0 ? Math.round((ideas.filter(i => i.status !== 'new').length / totalCount) * 100) : 0,
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'In Queue',
|
||||
value: ideas.filter(i => i.status === 'queued').length.toLocaleString(),
|
||||
subtitle: 'ready for tasks',
|
||||
icon: <ListIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/writer/tasks',
|
||||
// Widget 3: Completion Stats
|
||||
completion: {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Clusters', value: clusters.length, color: 'green' },
|
||||
{ label: 'Ideas', value: totalCount, color: 'amber' },
|
||||
{ label: 'In Tasks', value: ideas.filter(i => i.status !== 'new').length, color: 'purple' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content', value: ideas.filter(i => i.status === 'completed').length, color: 'blue' },
|
||||
{ label: 'Images', value: 0, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/analytics',
|
||||
},
|
||||
{
|
||||
title: 'Content Created',
|
||||
value: ideas.filter(i => i.status === 'completed').length.toLocaleString(),
|
||||
subtitle: `${totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'completed').length / totalCount) * 100) : 0}% completion`,
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/writer/content',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Idea-to-Content Pipeline: Ideas successfully converted into written content',
|
||||
value: totalCount > 0 ? Math.round((ideas.filter(i => i.status === 'completed').length / totalCount) * 100) : 0,
|
||||
color: 'success',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useSiteStore } from '../../store/siteStore';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
|
||||
import FormModal from '../../components/common/FormModal';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
@@ -704,37 +704,89 @@ export default function Keywords() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer */}
|
||||
{/* Module Metrics Footer - 3-Widget Layout */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Keywords',
|
||||
value: totalCount.toLocaleString(),
|
||||
subtitle: `in ${clusters.length} clusters`,
|
||||
icon: <ListIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/planner/keywords',
|
||||
submoduleColor="blue"
|
||||
threeWidgetLayout={{
|
||||
// Widget 1: Page Progress
|
||||
pageProgress: {
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'blue',
|
||||
metrics: [
|
||||
{ label: 'Keywords', value: totalCount },
|
||||
{ label: 'Clustered', value: keywords.filter(k => k.cluster_id).length, percentage: `${totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Unmapped', value: keywords.filter(k => !k.cluster_id).length },
|
||||
{ label: 'Volume', value: `${(keywords.reduce((sum, k) => sum + (k.volume || 0), 0) / 1000).toFixed(1)}K` },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
|
||||
label: 'Clustered',
|
||||
color: 'blue',
|
||||
},
|
||||
hint: keywords.filter(k => !k.cluster_id).length > 0
|
||||
? `${keywords.filter(k => !k.cluster_id).length} keywords ready to cluster`
|
||||
: 'All keywords clustered!',
|
||||
},
|
||||
{
|
||||
title: 'Clustered',
|
||||
value: keywords.filter(k => k.cluster_id).length.toLocaleString(),
|
||||
subtitle: `${Math.round((keywords.filter(k => k.cluster_id).length / Math.max(totalCount, 1)) * 100)}% organized`,
|
||||
icon: <GroupIcon className="w-5 h-5" />,
|
||||
accentColor: 'purple',
|
||||
href: '/planner/clusters',
|
||||
// Widget 2: Module Stats (Planner Pipeline)
|
||||
moduleStats: {
|
||||
title: 'Planner Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Keywords',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Auto Cluster',
|
||||
toLabel: 'Clusters',
|
||||
toValue: clusters.length,
|
||||
toHref: '/planner/clusters',
|
||||
progress: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Clusters',
|
||||
fromValue: clusters.length,
|
||||
fromHref: '/planner/clusters',
|
||||
actionLabel: 'Generate Ideas',
|
||||
toLabel: 'Ideas',
|
||||
toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
|
||||
toHref: '/planner/ideas',
|
||||
progress: clusters.length > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / clusters.length) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ideas',
|
||||
fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0),
|
||||
fromHref: '/planner/ideas',
|
||||
actionLabel: 'Create Tasks',
|
||||
toLabel: 'Tasks',
|
||||
toValue: 0,
|
||||
toHref: '/writer/tasks',
|
||||
progress: 0,
|
||||
color: 'amber',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Keywords', href: '/planner/keywords' },
|
||||
{ label: 'Clusters', href: '/planner/clusters' },
|
||||
{ label: 'Ideas', href: '/planner/ideas' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Easy Wins',
|
||||
value: keywords.filter(k => k.difficulty && k.difficulty <= 3 && (k.volume || 0) > 0).length.toLocaleString(),
|
||||
subtitle: `Low difficulty with ${keywords.filter(k => k.difficulty && k.difficulty <= 3).reduce((sum, k) => sum + (k.volume || 0), 0).toLocaleString()} volume`,
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
// Widget 3: Completion Stats
|
||||
completion: {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords', value: keywords.filter(k => k.cluster_id).length, color: 'blue' },
|
||||
{ label: 'Clusters', value: clusters.length, color: 'green' },
|
||||
{ label: 'Ideas', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content', value: 0, color: 'blue' },
|
||||
{ label: 'Images', value: 0, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/analytics',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Keyword Clustering Pipeline: Keywords organized into topical clusters',
|
||||
value: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0,
|
||||
color: 'primary',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
bulkDeleteContent,
|
||||
} from '../../services/api';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { FileIcon, CheckCircleIcon, BoltIcon } from '../../icons';
|
||||
import { RocketLaunchIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon, BoltIcon } from '../../icons';
|
||||
import { createApprovedPageConfig } from '../../config/pages/approved.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
@@ -358,29 +357,87 @@ export default function Approved() {
|
||||
getItemDisplayName={(row: Content) => row.title || `Content #${row.id}`}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer */}
|
||||
{/* Module Metrics Footer - 3-Widget Layout */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Approved Content',
|
||||
value: content.length.toLocaleString(),
|
||||
subtitle: 'ready for publishing',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
submoduleColor="green"
|
||||
threeWidgetLayout={{
|
||||
pageProgress: {
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'green',
|
||||
metrics: [
|
||||
{ label: 'Total Approved', value: totalCount },
|
||||
{ label: 'On Site', value: content.filter(c => c.external_id).length, percentage: `${totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Pending Publish', value: content.filter(c => !c.external_id).length },
|
||||
{ label: 'This Page', value: content.length },
|
||||
],
|
||||
progress: {
|
||||
label: 'Published to Site',
|
||||
value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
hint: content.filter(c => !c.external_id).length > 0
|
||||
? `${content.filter(c => !c.external_id).length} items ready for site publishing`
|
||||
: 'All approved content published!',
|
||||
},
|
||||
{
|
||||
title: 'Published to Site',
|
||||
value: content.filter(c => c.external_id).length.toLocaleString(),
|
||||
subtitle: 'on WordPress',
|
||||
icon: <RocketLaunchIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/writer/approved',
|
||||
moduleStats: {
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/tasks',
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: 0,
|
||||
toHref: '/writer/content',
|
||||
progress: 100,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: 0,
|
||||
toHref: '/writer/images',
|
||||
progress: 100,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/review',
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: totalCount,
|
||||
progress: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/approved' },
|
||||
],
|
||||
},
|
||||
completion: {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords', value: 0, color: 'blue' },
|
||||
{ label: 'Clusters', value: 0, color: 'green' },
|
||||
{ label: 'Ideas', value: 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content', value: 0, color: 'purple' },
|
||||
{ label: 'Images', value: 0, color: 'amber' },
|
||||
{ label: 'Published', value: content.filter(c => c.external_id).length, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/analytics',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Site Publishing Progress',
|
||||
value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
|
||||
color: 'success',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContent,
|
||||
@@ -16,14 +16,13 @@ import {
|
||||
} from '../../services/api';
|
||||
import { optimizerApi } from '../../api/optimizer.api';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { FileIcon, TaskIcon, CheckCircleIcon, ArrowRightIcon } from '../../icons';
|
||||
import { createContentPageConfig } from '../../config/pages/content.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
import { useProgressModal } from '../../hooks/useProgressModal';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { PencilSquareIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function Content() {
|
||||
@@ -275,45 +274,86 @@ export default function Content() {
|
||||
getItemDisplayName={(row: ContentType) => row.title || `Content #${row.id}`}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
{/* Module Metrics Footer - 3-Widget Layout */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Tasks',
|
||||
value: content.length.toLocaleString(),
|
||||
subtitle: 'generated from queue',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/writer/tasks',
|
||||
submoduleColor="purple"
|
||||
threeWidgetLayout={{
|
||||
pageProgress: {
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'purple',
|
||||
metrics: [
|
||||
{ label: 'Total Content', value: totalCount },
|
||||
{ label: 'Draft', value: content.filter(c => c.status === 'draft').length },
|
||||
{ label: 'In Review', value: content.filter(c => c.status === 'review').length },
|
||||
{ label: 'Published', value: content.filter(c => c.status === 'published').length, percentage: `${totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0}%` },
|
||||
],
|
||||
progress: {
|
||||
label: 'Published',
|
||||
value: totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
hint: content.filter(c => c.status === 'draft').length > 0
|
||||
? `${content.filter(c => c.status === 'draft').length} drafts need images before review`
|
||||
: 'All content processed!',
|
||||
},
|
||||
{
|
||||
title: 'Draft',
|
||||
value: content.filter(c => c.status === 'draft').length.toLocaleString(),
|
||||
subtitle: 'needs editing',
|
||||
icon: <FileIcon className="w-5 h-5" />,
|
||||
accentColor: 'amber',
|
||||
moduleStats: {
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: totalCount,
|
||||
fromHref: '/writer/tasks',
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: content.filter(c => c.status === 'draft').length,
|
||||
progress: 100,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: content.filter(c => c.status === 'draft').length,
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: 0,
|
||||
toHref: '/writer/images',
|
||||
progress: totalCount > 0 ? Math.round((content.filter(c => c.status !== 'draft').length / totalCount) * 100) : 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: content.filter(c => c.status === 'review').length,
|
||||
fromHref: '/writer/review',
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: content.filter(c => c.status === 'published').length,
|
||||
toHref: '/writer/approved',
|
||||
progress: totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/approved' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'In Review',
|
||||
value: content.filter(c => c.status === 'review').length.toLocaleString(),
|
||||
subtitle: 'awaiting approval',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
href: '/writer/review',
|
||||
completion: {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords', value: 0, color: 'blue' },
|
||||
{ label: 'Clusters', value: 0, color: 'green' },
|
||||
{ label: 'Ideas', value: 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content', value: totalCount, color: 'purple' },
|
||||
{ label: 'Images', value: 0, color: 'amber' },
|
||||
{ label: 'Published', value: content.filter(c => c.status === 'published').length, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/analytics',
|
||||
},
|
||||
{
|
||||
title: 'Published',
|
||||
value: content.filter(c => c.status === 'published').length.toLocaleString(),
|
||||
subtitle: 'ready for sync',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/writer/published',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Content Publishing Pipeline: Content moved from draft through review to published (Draft \u2192 Review \u2192 Published)',
|
||||
value: totalCount > 0 ? Math.round((content.filter(c => c.status === 'published').length / totalCount) * 100) : 0,
|
||||
color: 'success',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -455,15 +455,86 @@ export default function Review() {
|
||||
onRowAction={handleRowAction}
|
||||
/>
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Ready to Publish',
|
||||
value: content.length,
|
||||
subtitle: 'Total review items',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
submoduleColor="amber"
|
||||
threeWidgetLayout={{
|
||||
pageProgress: {
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'amber',
|
||||
metrics: [
|
||||
{ label: 'In Review', value: totalCount },
|
||||
{ label: 'This Page', value: content.length },
|
||||
{ label: 'Ready', value: content.filter(c => c.word_count && c.word_count > 0).length, percentage: `${totalCount > 0 ? Math.round((content.filter(c => c.word_count && c.word_count > 0).length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Pending', value: content.filter(c => !c.word_count || c.word_count === 0).length },
|
||||
],
|
||||
progress: {
|
||||
label: 'Ready for Approval',
|
||||
value: totalCount > 0 ? Math.round((content.filter(c => c.word_count && c.word_count > 0).length / totalCount) * 100) : 0,
|
||||
color: 'amber',
|
||||
},
|
||||
hint: totalCount > 0
|
||||
? `${totalCount} items in review queue awaiting approval`
|
||||
: 'No items in review queue',
|
||||
},
|
||||
]}
|
||||
moduleStats: {
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/tasks',
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: 0,
|
||||
toHref: '/writer/content',
|
||||
progress: 100,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: 0,
|
||||
toHref: '/writer/images',
|
||||
progress: 100,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: 0,
|
||||
toHref: '/writer/approved',
|
||||
progress: 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/approved' },
|
||||
],
|
||||
},
|
||||
completion: {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Keywords', value: 0, color: 'blue' },
|
||||
{ label: 'Clusters', value: 0, color: 'green' },
|
||||
{ label: 'Ideas', value: 0, color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Content', value: 0, color: 'purple' },
|
||||
{ label: 'In Review', value: totalCount, color: 'amber' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/analytics',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ import { createTasksPageConfig } from '../../config/pages/tasks.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
|
||||
import { DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function Tasks() {
|
||||
@@ -467,44 +467,89 @@ export default function Tasks() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module Metrics Footer - Pipeline Style with Cross-Module Links */}
|
||||
{/* Module Metrics Footer - 3-Widget Layout */}
|
||||
<ModuleMetricsFooter
|
||||
metrics={[
|
||||
{
|
||||
title: 'Ideas',
|
||||
value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0).toLocaleString(),
|
||||
subtitle: 'from planner',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'orange',
|
||||
href: '/planner/ideas',
|
||||
submoduleColor="blue"
|
||||
threeWidgetLayout={{
|
||||
// Widget 1: Page Progress (Tasks)
|
||||
pageProgress: {
|
||||
title: 'Page Progress',
|
||||
submoduleColor: 'blue',
|
||||
metrics: [
|
||||
{ label: 'Total', value: totalCount },
|
||||
{ label: 'Complete', value: tasks.filter(t => t.status === 'completed').length, percentage: `${totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0}%` },
|
||||
{ label: 'Queue', value: tasks.filter(t => t.status === 'queued').length },
|
||||
{ label: 'Processing', value: tasks.filter(t => t.status === 'in_progress').length },
|
||||
],
|
||||
progress: {
|
||||
value: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
|
||||
label: 'Generated',
|
||||
color: 'blue',
|
||||
},
|
||||
hint: tasks.filter(t => t.status === 'queued').length > 0
|
||||
? `${tasks.filter(t => t.status === 'queued').length} tasks in queue for content generation`
|
||||
: 'All tasks processed!',
|
||||
},
|
||||
{
|
||||
title: 'In Queue',
|
||||
value: tasks.filter(t => t.status === 'queued').length.toLocaleString(),
|
||||
subtitle: 'waiting for processing',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'amber',
|
||||
// Widget 2: Module Stats (Writer Pipeline)
|
||||
moduleStats: {
|
||||
title: 'Writer Module',
|
||||
pipeline: [
|
||||
{
|
||||
fromLabel: 'Tasks',
|
||||
fromValue: totalCount,
|
||||
actionLabel: 'Generate Content',
|
||||
toLabel: 'Drafts',
|
||||
toValue: tasks.filter(t => t.status === 'completed').length,
|
||||
toHref: '/writer/content',
|
||||
progress: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Drafts',
|
||||
fromValue: tasks.filter(t => t.status === 'completed').length,
|
||||
fromHref: '/writer/content',
|
||||
actionLabel: 'Generate Images',
|
||||
toLabel: 'Images',
|
||||
toValue: 0,
|
||||
toHref: '/writer/images',
|
||||
progress: 0,
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
fromLabel: 'Ready',
|
||||
fromValue: 0,
|
||||
fromHref: '/writer/review',
|
||||
actionLabel: 'Review & Publish',
|
||||
toLabel: 'Published',
|
||||
toValue: 0,
|
||||
toHref: '/writer/approved',
|
||||
progress: 0,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ label: 'Tasks', href: '/writer/tasks' },
|
||||
{ label: 'Content', href: '/writer/content' },
|
||||
{ label: 'Images', href: '/writer/images' },
|
||||
{ label: 'Published', href: '/writer/approved' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Processing',
|
||||
value: tasks.filter(t => t.status === 'in_progress').length.toLocaleString(),
|
||||
subtitle: 'generating content',
|
||||
icon: <TaskIcon className="w-5 h-5" />,
|
||||
accentColor: 'blue',
|
||||
// Widget 3: Completion Stats
|
||||
completion: {
|
||||
title: 'Workflow Completion',
|
||||
plannerItems: [
|
||||
{ label: 'Clusters', value: clusters.length, color: 'green' },
|
||||
{ label: 'Ideas', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' },
|
||||
],
|
||||
writerItems: [
|
||||
{ label: 'Tasks', value: totalCount, color: 'blue' },
|
||||
{ label: 'Content', value: tasks.filter(t => t.status === 'completed').length, color: 'purple' },
|
||||
{ label: 'Published', value: 0, color: 'green' },
|
||||
],
|
||||
creditsUsed: 0,
|
||||
operationsCount: 0,
|
||||
analyticsHref: '/analytics',
|
||||
},
|
||||
{
|
||||
title: 'Ready for Review',
|
||||
value: tasks.filter(t => t.status === 'completed').length.toLocaleString(),
|
||||
subtitle: 'content generated',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
accentColor: 'green',
|
||||
href: '/writer/content',
|
||||
},
|
||||
]}
|
||||
progress={{
|
||||
label: 'Content Generation Pipeline: Tasks successfully completed (Queued → Processing → Completed)',
|
||||
value: totalCount > 0 ? Math.round((tasks.filter(t => t.status === 'completed').length / totalCount) * 100) : 0,
|
||||
color: 'success',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2619,3 +2619,117 @@ export async function generatePageContent(
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Dashboard Summary API
|
||||
// ==========================================
|
||||
|
||||
export interface DashboardAttentionItem {
|
||||
id: string;
|
||||
type: 'pending_review' | 'setup_incomplete' | 'credits_low' | 'no_integration' | 'queued_tasks' | 'sync_failed';
|
||||
title: string;
|
||||
count?: number;
|
||||
action_label: string;
|
||||
action_url: string;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export interface DashboardPipeline {
|
||||
keywords: number;
|
||||
clusters: number;
|
||||
ideas: number;
|
||||
tasks: number;
|
||||
drafts: number;
|
||||
review: number;
|
||||
published: number;
|
||||
total_content: number;
|
||||
completion_percentage: number;
|
||||
}
|
||||
|
||||
export interface DashboardAIOperation {
|
||||
type: string;
|
||||
label: string;
|
||||
count: number;
|
||||
credits: number;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export interface DashboardAIOperations {
|
||||
period_days: number;
|
||||
operations: DashboardAIOperation[];
|
||||
totals: {
|
||||
credits: number;
|
||||
operations: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardActivity {
|
||||
id: number;
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
credits: number;
|
||||
}
|
||||
|
||||
export interface DashboardContentVelocity {
|
||||
today: number;
|
||||
this_week: number;
|
||||
this_month: number;
|
||||
daily: Array<{ date: string; count: number }>;
|
||||
average_per_day: number;
|
||||
}
|
||||
|
||||
export interface DashboardAutomation {
|
||||
enabled: boolean;
|
||||
active_count: number;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export interface DashboardSite {
|
||||
id: number;
|
||||
name: string;
|
||||
domain: string;
|
||||
keywords: number;
|
||||
content: number;
|
||||
published: number;
|
||||
has_integration: boolean;
|
||||
sectors_count: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
needs_attention: DashboardAttentionItem[];
|
||||
pipeline: DashboardPipeline;
|
||||
ai_operations: DashboardAIOperations;
|
||||
recent_activity: DashboardActivity[];
|
||||
content_velocity: DashboardContentVelocity;
|
||||
automation: DashboardAutomation;
|
||||
sites: DashboardSite[];
|
||||
account: {
|
||||
credits: number;
|
||||
name: string;
|
||||
};
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface DashboardSummaryFilters {
|
||||
site_id?: number;
|
||||
days?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch aggregated dashboard summary in a single API call.
|
||||
* Replaces multiple sequential calls for better performance.
|
||||
*/
|
||||
export async function fetchDashboardSummary(
|
||||
filters: DashboardSummaryFilters = {}
|
||||
): Promise<DashboardSummary> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.site_id) params.append('site_id', String(filters.site_id));
|
||||
if (filters.days) params.append('days', String(filters.days));
|
||||
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/v1/account/dashboard/summary/${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user